函数传参是每种语言最基础的功能,却也是跨语言理解时最容易踩坑的地方。同样一句"传引用",在 C++ 里是语言级特性,在 Java/Python 里却常常被误用。根本原因在于"变量"和"对象"在不同语言里建模不同–有的语言变量是"装值的盒子",有的则是"贴在对象上的标签"。本文用一个统一的分析框架,梳理 C/C++、Java、Go、Rust、Python 五种语言的传参语义。

先建立分析框架:三种语义模型

讲清传参,必须区分三件事:

  1. 实参本身被复制了没有;
  2. 被调用方能否修改调用方的对象内容;
  3. 被调用方能否重绑定调用方的变量名(让它指向另一个对象)。

很多人把 2 和 3 混为一谈,而区分它们恰恰是理解 Java、Python 的关键。

传值(pass by value)

形参是实参的副本。修改形参不影响实参。C 的所有传参本质上都是传值(包括指针,指针也是被拷贝的值)。

传引用(pass by reference)

形参是实参的别名,指向同一块存储。修改形参就是修改实参,重绑定形参也会影响实参。C++ 的 T& 是真正意义上的传引用。

传共享(pass by sharing)

形参得到的是"对象引用"的副本–形参和实参指向同一个对象,但形参这个引用变量本身是副本。因此:修改对象内容对外可见,重新绑定形参对外不可见。Java、Python、Go 的引用类型都属于这一族。

关键洞察:传共享在形式上仍是"传值",只是传的值恰好是一个引用/地址。所以"Java 是传值"与"Java 传的是对象引用"并不矛盾–说的是同一件事。

C:万物皆传值

C 只有传值。要修改调用方的变量,必须传指针:

1
2
3
4
5
6
void inc(int x) { x++; }       // 无效:改的是副本
void inc(int *x) { (*x)++; } // 有效:通过地址改原对象

int n = 0;
inc(n); // n 仍为 0
inc(&n); // n 变为 1

指针本身也是传值–拷贝一份地址。在 void f(int *p) 内部把 p 改指向别处,不影响外部指针;但 *p 操作的是同一块内存。

数组传参会退化为指针:void f(int a[]) 等价于 void f(int *a),长度信息丢失(这也是为何数组参数总要单独传长度)。

swap 必须借助指针:

1
2
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
swap(&x, &y);

C++:在传值之外,多了引用与右值

C++ 继承了 C 的传值与指针,并新增引用语义:

1
2
3
4
void f(int x);            // 传值:拷贝
void f(int& x); // 传引用:别名,可改外部
void f(const int& x); // 传 const 引用:只读、不拷贝
void f(int* x); // 传指针

引用是 C++ 传参的核心工具。其中 const T& 尤其重要–既避免拷贝大对象,又禁止修改,是只读大对象传参的默认选择,且能绑定到右值(临时对象)。

现代 C++ 传参经验法则:

场景推荐形式说明
只读小对象 / 内置类型传值 T拷贝廉价,且利于优化
只读大对象const T&避免拷贝
需要修改实参T&真正的引用
接收所有权(独占)传值 T调用方 std::move 进来,零拷贝
可空 / 可选 / 可重指向T* / const T*& 更灵活
实现移动语义 / 完美转发T&&右值引用

"接收所有权"用传值 + move 的现代写法非常简洁:

1
2
3
4
void take(std::string s) { /* 内部独占 s */ }

std::string big = make();
take(std::move(big)); // 无拷贝,big 移动进 take

右值引用 T&& 主要用于实现移动构造/移动赋值和完美转发(std::forward),日常函数传参不常直接写–现代写法更倾向"传值 + move"。

Java:基本类型传值,引用类型传引用的副本

Java 的变量分两类:

  • 基本类型int double boolean 等 8 种):存值本身;
  • 引用类型(对象、数组):存引用(可理解为地址)。

两者传参都是传值:基本类型拷贝值本身,引用类型拷贝的是引用(指向对象的地址)。形参拿到的是引用的一份副本–和实参指向同一个对象,但不是同一个引用变量。后果是:

1
2
3
4
5
6
void rebind(Person p) {
p = new Person("B"); // 无效:改的是形参这份引用,碰不到实参
}
void rename(Person p) {
p.setName("B"); // 有效:形参与实参指向同一对象,改内容对外可见
}

对基本类型,swap 完全不可能:

1
void swap(int a, int b) { int t = a; a = b; b = t; }  // 完全无效

要"交换"得用数组或单元素包装类绕过。

"Java 是传值还是传引用"是经久不息的口水战。准确表述:Java 是传值,引用类型的"值"就是引用本身。这与"传共享"等价。

Go:一切传值,与"看起来像引用"的切片

Go 明确声明:所有传参都是传值,没有 C++ 那种引用类型。

1
2
func inc(x int)      // 传值,改不了外部
func inc(x *int) // 传指针副本,*x 可改外部

slice / map / channel 看起来像引用,因为它们底层就是含指针的小结构:

  • map / channel:本质是指针。传副本后仍指向同一底层数据,m[k] = v 对外部可见,但 m = make(...) 重赋值不可见。
  • slice:是"切片头"(指针 + 长度 + 容量)的副本。修改已有元素对外可见,但 append 可能触发扩容并返回新底层数组–局部 s = append(s, x) 不会反映到外部。
1
2
3
4
func modifySlice(s []int) {
s[0] = 99 // 可见:改的是共享的底层数组
s = append(s, 1) // 不可见:重赋值了局部 header
}

所以 Go 惯用返回值而非 out 参数:

1
2
3
4
func appendGood(s []int, v int) []int {
return append(s, v)
}
s = appendGood(s, 1)

结构体默认传值拷贝(逐字段浅拷贝),想避免拷贝或要修改原对象就传 *T

Rust:把传参语义写进所有权类型

Rust 没有传统的"引用参数"语法糖,而是用所有权与借用表达传参语义,并在编译期检查:

1
2
3
4
fn take(s: String)              // 接收所有权,调用方失去 s
fn borrow(s: &String) // 不可变借用:可读不可改,可多次借用
fn borrow_mut(s: &mut String) // 可变借用:可改,独占
fn copy_type(x: i32) // i32: Copy,传值拷贝,调用方仍持有
  • 实现了 Copy trait 的类型(整数、浮点、bool、不可变引用 &T 等)传值时拷贝,调用方仍可用。
  • 非 Copy 类型(如 StringVec<T>)传值时移动,调用方失效。
  • 只读借用 &T 避免拷贝又不取走所有权,类似 C++ 的 const T&
  • 可变借用 &mut T 类似 C++ 的 T&,但遵循"一个可变借用,或任意多个不可变借用"的排他规则。

最佳实践:

场景推荐形式
只读&T(字符串推荐 &str,序列推荐 &[T]
需要修改&mut T
接收所有权传值 T
交还所有权返回 T
1
2
3
4
5
6
7
8
9
fn greet(name: &str) {            // 借用,不取走所有权
println!("hi, {}", name);
}

let s = String::from("Alice");
greet(&s);
println!("{}", s); // OK:s 仍可用

take(s); // s 移动,此后不可用

Python:一切皆对象,变量是名字

Python 的变量不是"装值的盒子",而是贴在对象上的名字标签。赋值是给对象贴标签,传参则是给同一个对象再贴一个(函数内的局部)标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
def rebind(x):
x = x + 1 # 局部标签 x 重新贴到新对象,外部无感

def modify(lst):
lst.append(4) # 通过标签操作可变对象本身,外部可见

n = 10
rebind(n)
print(n) # 10

lst = [1, 2, 3]
modify(lst)
print(lst) # [1, 2, 3, 4]

关键在可变 vs 不可变

  • 不可变对象int str tuple frozenset):任何"修改"都产生新对象,等价于重绑定,外部不可见。
  • 可变对象list dict set):可原地修改,外部可见。

Python 不能在函数内重绑定调用方的变量名,也没有指针,因此无法实现真正的 swap:

1
2
def swap(a, b):
a, b = b, a # 只交换了局部标签

Python 是"传对象引用"(pass by object reference),又称传共享–本质仍是传值,传的值是引用。

横向对比:两个判别测试

测试一:能否在函数内修改外部标量?

语言能否机制
C传指针 T*
C++传引用 T&
Java基本类型无法;对象可改内容
Go传指针 *T
Rust&mut T
Python不可变对象无原地修改

测试二:能否实现 swap(a, b)

语言能否说明
C指针
C++引用,最优雅
Java基本类型不行,需数组/包装绕过
Go指针(但惯用返回值)
Rust&mut T
Python无指针,无法重绑定

总结

语言传参模型修改外部标量swap对象/聚合类型
C传值(含指针)T*需指针数组退化为指针
C++传值/引用/指针/右值T&T&const T& 只读
Java传值(引用即值)改内容可,重绑定否
Go传值*T*Tslice/map/chan 底层指针
Rust所有权/借用&mut T&mut T&T 借用
Python传对象引用可变对象可原地改

传参的差异,本质是各语言对"变量"“对象”"所有权"建模不同:

  • C/C++ 把控制权交给程序员,用指针/引用显式表达意图;
  • Java/Python 隐藏地址,用"对象引用"统一模型,代价是失去在函数内重绑定调用方变量的能力;
  • Go 坚持显式传值,用返回值解决 out 参数需求;
  • Rust 用所有权系统把借用规则写进类型签名与编译期。

理解了"传值 vs 传引用 vs 传共享"这个三角,以及"改内容 vs 重绑定"这个区分,跨语言传参就不再混淆。