Copy 与 Clone:两种复制策略

上一篇看到切片可以随便复制而数据不行,背后正是 Copy 与 Clone 的分工。这篇解决的问题是:这两个 trait 各自定义了什么复制语义、什么类型能实现 Copy、#[derive] 自动实现有什么限制、以及什么时候你手写的 Clone 实现会出错。读完你再看到 clone() 调用,能立刻判断这是一次必要的复制还是可以消除的性能浪费。

一、Clone:显式复制的通用协议

Clone 是「如何复制我自己」的通用接口,核心方法 clone(&self) -> Self。它的语义是深复制——生成一个与原值等价的新值,互不影响。所有涉及堆数据的类型都靠它来完成完整复制:

fn main() {
    let original = String::from("你好");
    let duplicate = original.clone();  // 分配新堆内存
    println!("{} {}", original, duplicate);
}

Clone 必须显式调用,编译器从不替你自动深复制。这个设计把复制成本摆在台面上:每写一个 clone,你都该意识到这里发生了一次内存分配。实现 Clone 通常用 #[derive(Clone)] 让编译器逐字段克隆,只有当字段类型全部实现了 Clone 时才能派生成功。

值得对比的是 C++ 的隐式拷贝构造:那里对象的复制悄无声息地发生在传参、返回、赋值各处,程序员需要靠「三法则」「五法则」一行行管理资源,漏掉一处就是双重释放。Rust 把复制变成必须写出来的方法调用,等于把 C++ 里最容易漏掉的那段心智成本转移到了代码可见性上——读代码的人看一眼就知道这里发生了复制。

Clone 的方法不止 clone:clone_from 在已有对象上原地复制(默认实现等价于先 drop 再 clone,标准库类型如 String 重写了它以复用已有缓冲);clone_into 则把内容写进给定的目标,用于复用容器。性能敏感路径值得认识这两个方法。

二、Copy:赋值即复制的特权

Copy 是 Clone 的特殊形态:实现了 Copy 的类型在赋值、传参、函数返回时按位复制,原变量继续可用,不需要任何方法调用。它没有自己的方法,只是向编译器声明一个事实——复制这种类型就是逐位拷贝,没有资源需要特殊处理。

对比项 Copy Clone
触发方式 赋值自动发生 显式调用 clone()
实现要求 位复制即可 任意深复制逻辑
可与 Drop 共存 否 是
典型类型 i32、bool、char String、Vec、自定义结构
成本 轻到可忽略 可能涉及分配

一个硬性规则:实现了 Drop 的类型不能实现 Copy。理由很直接——Drop 意味着析构时有事情要做(释放堆、关文件句柄),位复制会漏掉这些,导致双重释放。编译器会直接拒绝同时实现这两个 trait 的代码。

还有一个反直觉细节:&mut T 不是 Copy,而 &T 是。因为可变引用本身携带「独占权」,复制两份就破坏了借用规则;不可变引用可以随意复制,共享只读不构成风险。这个差别在你把引用塞进集合、跨函数传递时会反复显现,属于借用规则在 Copy 上的投影。

三、什么类型能 derive Copy

派生 Copy 的条件是所有字段都是 Copy 类型,且类型本身没有实现 Drop。标量、&T(共享引用是 Copy,因为引用只是个地址)、固定数组、元素全为 Copy 的元组都满足。而 String、Vec、Box 这些持堆数据的类型不满足,含它们的 struct 自然也不能 Copy:

#[derive(Copy, Clone)]
struct Point { x: f64, y: f64 }   // 全是 f64,可以 Copy

#[derive(Clone)]
struct Named { p: Point, tag: String } // 含 String,只能 Clone

fn main() {
    let a = Point { x: 1.0, y: 2.0 };
    let b = a;                     // 按位复制,a 仍可用
    println!("{} {} {}", a.x, b.x);
}

注意派生顺序:#[derive(Copy, Clone)] 中 Clone 在后会报错,因为 Copy 的实现依赖 Clone。标准做法是两个都写,编译器有固定顺序要求。

判断一个类型能不能 Copy,有个更本质的视角:问自己「复制它之后,两份会不会共享需要管理的资源」。答案是不会,就是 Copy 候选;会,就只能 Clone。

顺带记住一条性能直觉:Copy 类型通常也是缓存友好的连续数据,复制时可以整块 memcpy,而含指针的结构只能逐个追踪元素,实际吞吐差距可达数量级。这就是为什么性能敏感的核心数据结构(坐标、颜色、位掩码、小型枚举)都尽量做成 Copy,让赋值和传参都走最快的路径。

四、Clone 在错误信息里的角色

新手常被一类错误困住:cannot move out of ... 之类,常见于从容器或结构体里取出部分数据。clone() 往往是合法出路:

fn main() {
    let names = vec![String::from("张三"), String::from("李四")];
    let first = names[0].clone(); // 不 clone 无法把 String 移出 Vec
    println!("{} 其余 {:?}", first, names);
}

这是「clone 作为逃生口」的典型场景,但也要警惕把它当万能膏药:很多看似需要 clone 的位置,其实用引用或重构数据所有权就能消掉。判断标准很简单——如果克隆只是为了「让编译器闭嘴」,通常有更好的解法;如果是要在所有权被 move 之后再独立使用一份,那就是真需要。

补充一个常被问到的问题:Options/Result 包裹的 String 怎么从容器里取出来?惯用法是 maybe.clone() 或者模式匹配后 clone,也可以直接 match 出引用再 clone 字段。没有「move out of container」的免费午餐,这是 Vec / HashMap 持有所有权的直接后果,试图绕开它反而会遇到更晦涩的报错。

五、性能与陷阱

Clone 的性能语义容易产生两个误区。其一是低估:大量小字符串的 clone 在热路径上损耗可观,profile 里经常意外成为热点;其二是高估:Rc、Arc 这类共享指针的 clone 只增加引用计数,成本极低,共享比复制更合适时别舍不得。处理树形结构、事件分发时 Arc<T> 的 clone 是常规操作。

场景 建议做法 理由
标量小结构 直接复制或 derive Copy 无成本
偶尔复制的 String clone 语义清晰
高频共享只读数据 Arc/Rc 计数远比分配便宜
长期持有的解析中间结果 借用切片或 Cow 从源头避免复制

手写 Clone 时注意保持与 PartialEq 等其他派生 trait 的一致性:clone 出的值如果参与相等比较,一定要保证它和原值相等,否则测试会以奇妙方式失败。以官方文档为准,自定义 Clone 实现的完整 API 约定在 std 文档里有说明。

判断 clone 是否必要的最后一个小技巧:临时把 clone() 调用全注释掉,看编译器在哪几个具体位置强制要求,这几处才是真正不可消除的复制点。其余能靠借用过去的,改完之后往往还能顺手简化数据流。写桌面应用时这套排查尤其有用,Tauri 桌面开发指南 里前后端数据传递的例子就演示了如何用序列化边界代替大量 clone,是最直接的复用经验。

顺带一个性能直觉:clone 的成本取决于目标类型,不是所有 clone 都贵。复制一个 usize 或几个字节的枚举与一次函数调用同价;真正需要掂量的是包含堆分配的类型,比如 String、Vec 以及内含它们的 struct。 profile 工具显示的热点几乎从不出现在标量 clone 上,盲目消除一切 clone 只会让代码变难读。

延伸阅读

  • 下一篇「函数与表达式」开始把数据组织成逻辑,move 与 clone 的知识会在参数传递里反复用到,正好接着练手。
  • 处理大文本时的复制策略可以参考 正则表达式精进 的零拷贝示例,与切片一章连读效果最好,两者合起来覆盖了「什么时候该复制」的主要决策树。
  • 智能指针一章会展开 Rc 与 Arc 的引用计数机制,本篇只做个预告,等学到那里再把这张选型表补全。