函数传参
函数传参是每种语言最基础的功能,却也是跨语言理解时最容易踩坑的地方。同样一句"传引用",在 C++ 里是语言级特性,在 Java/Python 里却常常被误用。根本原因在于"变量"和"对象"在不同语言里建模不同–有的语言变量是"装值的盒子",有的则是"贴在对象上的标签"。本文用一个统一的分析框架,梳理 C/C++、Java、Go、Rust、Python 五种语言的传参语义。
先建立分析框架:三种语义模型
讲清传参,必须区分三件事:
- 实参本身被复制了没有;
- 被调用方能否修改调用方的对象内容;
- 被调用方能否重绑定调用方的变量名(让它指向另一个对象)。
很多人把 2 和 3 混为一谈,而区分它们恰恰是理解 Java、Python 的关键。
传值(pass by value)
形参是实参的副本。修改形参不影响实参。C 的所有传参本质上都是传值(包括指针,指针也是被拷贝的值)。
传引用(pass by reference)
形参是实参的别名,指向同一块存储。修改形参就是修改实参,重绑定形参也会影响实参。C++ 的 T& 是真正意义上的传引用。
传共享(pass by sharing)
形参得到的是"对象引用"的副本–形参和实参指向同一个对象,但形参这个引用变量本身是副本。因此:修改对象内容对外可见,重新绑定形参对外不可见。Java、Python、Go 的引用类型都属于这一族。
关键洞察:传共享在形式上仍是"传值",只是传的值恰好是一个引用/地址。所以"Java 是传值"与"Java 传的是对象引用"并不矛盾–说的是同一件事。
C:万物皆传值
C 只有传值。要修改调用方的变量,必须传指针:
1 | |
指针本身也是传值–拷贝一份地址。在 void f(int *p) 内部把 p 改指向别处,不影响外部指针;但 *p 操作的是同一块内存。
数组传参会退化为指针:void f(int a[]) 等价于 void f(int *a),长度信息丢失(这也是为何数组参数总要单独传长度)。
swap 必须借助指针:
1 | |
C++:在传值之外,多了引用与右值
C++ 继承了 C 的传值与指针,并新增引用语义:
1 | |
引用是 C++ 传参的核心工具。其中 const T& 尤其重要–既避免拷贝大对象,又禁止修改,是只读大对象传参的默认选择,且能绑定到右值(临时对象)。
现代 C++ 传参经验法则:
| 场景 | 推荐形式 | 说明 |
|---|---|---|
| 只读小对象 / 内置类型 | 传值 T | 拷贝廉价,且利于优化 |
| 只读大对象 | const T& | 避免拷贝 |
| 需要修改实参 | T& | 真正的引用 |
| 接收所有权(独占) | 传值 T | 调用方 std::move 进来,零拷贝 |
| 可空 / 可选 / 可重指向 | T* / const T* | 比 & 更灵活 |
| 实现移动语义 / 完美转发 | T&& | 右值引用 |
"接收所有权"用传值 + move 的现代写法非常简洁:
1 | |
右值引用 T&& 主要用于实现移动构造/移动赋值和完美转发(std::forward),日常函数传参不常直接写–现代写法更倾向"传值 + move"。
Java:基本类型传值,引用类型传引用的副本
Java 的变量分两类:
- 基本类型(
intdoubleboolean等 8 种):存值本身; - 引用类型(对象、数组):存引用(可理解为地址)。
两者传参都是传值:基本类型拷贝值本身,引用类型拷贝的是引用(指向对象的地址)。形参拿到的是引用的一份副本–和实参指向同一个对象,但不是同一个引用变量。后果是:
1 | |
对基本类型,swap 完全不可能:
1 | |
要"交换"得用数组或单元素包装类绕过。
"Java 是传值还是传引用"是经久不息的口水战。准确表述:Java 是传值,引用类型的"值"就是引用本身。这与"传共享"等价。
Go:一切传值,与"看起来像引用"的切片
Go 明确声明:所有传参都是传值,没有 C++ 那种引用类型。
1 | |
但 slice / map / channel 看起来像引用,因为它们底层就是含指针的小结构:
- map / channel:本质是指针。传副本后仍指向同一底层数据,
m[k] = v对外部可见,但m = make(...)重赋值不可见。 - slice:是"切片头"(指针 + 长度 + 容量)的副本。修改已有元素对外可见,但
append可能触发扩容并返回新底层数组–局部s = append(s, x)不会反映到外部。
1 | |
所以 Go 惯用返回值而非 out 参数:
1 | |
结构体默认传值拷贝(逐字段浅拷贝),想避免拷贝或要修改原对象就传 *T。
Rust:把传参语义写进所有权类型
Rust 没有传统的"引用参数"语法糖,而是用所有权与借用表达传参语义,并在编译期检查:
1 | |
- 实现了
Copytrait 的类型(整数、浮点、bool、不可变引用&T等)传值时拷贝,调用方仍可用。 - 非 Copy 类型(如
String、Vec<T>)传值时移动,调用方失效。 - 只读借用
&T避免拷贝又不取走所有权,类似 C++ 的const T&。 - 可变借用
&mut T类似 C++ 的T&,但遵循"一个可变借用,或任意多个不可变借用"的排他规则。
最佳实践:
| 场景 | 推荐形式 |
|---|---|
| 只读 | &T(字符串推荐 &str,序列推荐 &[T]) |
| 需要修改 | &mut T |
| 接收所有权 | 传值 T |
| 交还所有权 | 返回 T |
1 | |
Python:一切皆对象,变量是名字
Python 的变量不是"装值的盒子",而是贴在对象上的名字标签。赋值是给对象贴标签,传参则是给同一个对象再贴一个(函数内的局部)标签。
1 | |
关键在可变 vs 不可变:
- 不可变对象(
intstrtuplefrozenset):任何"修改"都产生新对象,等价于重绑定,外部不可见。 - 可变对象(
listdictset):可原地修改,外部可见。
Python 不能在函数内重绑定调用方的变量名,也没有指针,因此无法实现真正的 swap:
1 | |
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 | *T | slice/map/chan 底层指针 |
| Rust | 所有权/借用 | &mut T | &mut T | &T 借用 |
| Python | 传对象引用 | 否 | 否 | 可变对象可原地改 |
传参的差异,本质是各语言对"变量"“对象”"所有权"建模不同:
- C/C++ 把控制权交给程序员,用指针/引用显式表达意图;
- Java/Python 隐藏地址,用"对象引用"统一模型,代价是失去在函数内重绑定调用方变量的能力;
- Go 坚持显式传值,用返回值解决 out 参数需求;
- Rust 用所有权系统把借用规则写进类型签名与编译期。
理解了"传值 vs 传引用 vs 传共享"这个三角,以及"改内容 vs 重绑定"这个区分,跨语言传参就不再混淆。

