返回文章列表

文章

Rust:函数传参一定会 move 吗?从 Tuple、Copy 到 Ownership

通过 tuple 传参示例,理解 Rust 中按值传参、move、Copy,以及为什么同一个变量可以被多次作为函数参数传递。

目录
  1. 1. 示例:为什么 coords 可以使用两次?
  2. 2. 按值传参并不意味着一定发生 move
  3. 3. 为什么 (i32, i32) 是 Copy?
  4. 4. 从所有权角度看发生了什么?
  5. 5. 换成 String,情况完全不同
  6. 6. 为什么 String 不能 Copy?
  7. 7. Tuple 的 Copy 是递归的
  8. 8. 一个实用的判断模型
  9. 9. Copy 与 move 的核心关系
  10. 10. 最后再看开头的代码

很多 Rust 初学者第一次看到下面的代码时,会产生一个非常自然的疑问:coords 第一次作为参数传给 move_coords 后,不是应该被 move 掉了吗?为什么第二次还能继续使用?

1. 示例:为什么 coords 可以使用两次?#

fn main() {
    let coords = (0, 1);
    let result = move_coords(coords);
    assert_eq!(result, (1, 2));

    let (x, y) = move_coords(coords);
    assert_eq!(x, 1);
    assert_eq!(y, 2);
}

fn move_coords(x: (i32, i32)) -> (i32, i32) {
    (x.0 + 1, x.1 + 1)
}

这段代码可以正常编译。关键不在于函数名 move_coords,而在于参数类型 (i32, i32) 是否实现了 Copy。

2. 按值传参并不意味着一定发生 move#

看到 foo(x) 时,不要机械地认为 x 一定被 move。 更准确的判断方式是:

  • 按值传递的是 Copy 类型:复制值,原变量仍然有效。
  • 按值传递的是非 Copy 类型:所有权转移,原变量通常不能继续使用。 因此,“函数传参 = move”是一个常见但不准确的初学者模型。

3. 为什么 (i32, i32) 是 Copy?#

i32 实现了 Copy。Rust 对 tuple 有一个重要规则:当 tuple 的所有元素都实现 Copy 时,这个 tuple 也实现 Copy。

i32       → Copy
i32       → Copy
────────────────────
(i32,i32) → Copy

所以第一次调用 move_coords(coords) 时,函数参数获得的是 coords 的 Copy,而原来的 coords 仍然有效。第二次调用自然也可以继续使用它。

4. 从所有权角度看发生了什么?#

可以简化理解成:

coords = (0, 1)
   │
   ├── Copy ──→ 第一次 move_coords 的参数 x
   │
   └── Copy ──→ 第二次 move_coords 的参数 x

函数名 move_coords 不会决定所有权行为。它只是普通的函数名。真正决定行为的是参数的类型。

5. 换成 String,情况完全不同#

fn foo(s: String) {
    println!("{s}");
}

fn main() {
    let s = String::from("hello");

    foo(s);
    println!("{s}");
}

这段代码无法编译,因为 String 没有实现 Copy。 第一次调用 foo(s) 会把 s 的所有权转移给函数参数。函数结束后,参数负责释放这个 String,调用方原来的变量已经不能继续使用。

6. 为什么 String 不能 Copy?#

String 管理着堆上的字符串数据,可以抽象成:

栈上:
┌──────────────┐
│ ptr          │ ───────→ 堆上的 "hello"
│ len          │
│ capacity     │
└──────────────┘

如果 String 可以隐式 Copy,两个 String 可能拥有指向同一块堆内存的状态,并最终重复释放同一资源。因此 Rust 不允许这种类型实现 Copy,而采用 move 语义来保证资源只有一个拥有者。 严格来说,move 在底层通常可能只是复制栈上的表示;关键区别在于 Rust 会让旧变量失效,从而避免双重释放等问题。

7. Tuple 的 Copy 是递归的#

let a = (1, 2, 'c');

这个 tuple 是 Copy,因为 i32、i32 和 char 都是 Copy。 但:

let b = (String::from("hello"), 1);

不是 Copy,因为其中的 String 不是 Copy。 可以记成:

i32   → Copy
i32   → Copy
char  → Copy
──────────────
Tuple → Copy

而:

String → 非 Copy
i32    → Copy
───────────────
Tuple  → 非 Copy

8. 一个实用的判断模型#

以后看到:

foo(x);

按照下面的顺序判断:

      x 按值传参
           │
           ▼
      查看 x 的类型
           │
     ┌─────┴─────┐
     │           │
  Copy 类型    非 Copy 类型
     │           │
     ▼           ▼
    Copy        Move
     │           │
     ▼           ▼
x 仍然可用     x 失效

常见类型:

类型Copy按值传参后原变量
i32✅可以继续使用
u64✅可以继续使用
f64✅可以继续使用
bool✅可以继续使用
char✅可以继续使用
(i32, i32)✅可以继续使用
(i32, char)✅可以继续使用
&str✅可以继续使用
String❌move 后不能继续使用
Vec<i32>❌move 后不能继续使用
Box<T>❌move 后不能继续使用

9. Copy 与 move 的核心关系#

     按值使用一个值
           │
           ▼
     类型是否 Copy?
       /       \
     是         否
     │           │
     ▼           ▼
   Copy        Move
     │           │
     ▼           ▼
原变量可用    原变量失效

因此,学习 Rust 所有权时不要形成“函数传参就会 move”的固定印象。 更准确的模型是:

按值传参 + Copy 类型 = Copy,原变量仍然有效。

按值传参 + 非 Copy 类型 = move,原变量不再拥有该值。

10. 最后再看开头的代码#

let coords = (0, 1);
let result = move_coords(coords);
assert_eq!(result, (1, 2));

let (x, y) = move_coords(coords);
assert_eq!(x, 1);
assert_eq!(y, 2);

这里的关键链路只有一条:

coords
  ↓
(i32, i32)
  ↓
(i32, i32): Copy
  ↓
第一次调用执行 Copy
  ↓
coords 仍然有效
  ↓
第二次调用仍然合法

不要只根据语法形式判断 Rust 的所有权行为,要结合具体类型的 Copy / 非 Copy 语义来判断。