编程语言中的求值策略
每隔一段时间,关于"Java 到底是值传递还是引用传递"的争论就会在论坛上重新点燃。一方拿出修改对象属性会影响外部的例子,宣称是引用传递;另一方拿出重新赋值不会影响外部的例子,反驳说是值传递。两边都能找到自圆其说的代码,谁也说服不了谁。
要把这件事说清楚,需要先从一个更上层的概念入手——求值策略 (Evaluation Strategy)。Java 的参数传递行为只是它求值策略的一个侧面;当我们有了完整的词汇表,"值传递还是引用传递"这个二元问题本身就消失了。
形参与实参
在开始之前,先固定两个会反复用到的术语:
- 形参 (Parameter):函数定义时声明的参数,用于在函数体内接收传入的值。
def foo(a: int)中的a就是形参。 - 实参 (Argument):函数调用时实际写在括号里的表达式。
y = foo(x)中的x就是实参。
求值策略关心的,正是实参如何被求值,以及它的值如何与形参建立联系。
求值策略是什么
在计算机科学中,求值策略是确定编程语言中表达式如何求值的一组规则。重点典型地位于函数或算子上——求值策略定义何时、以何种次序求值给函数的实际参数,什么时候把它们代换入函数,以及代换以何种形式发生。
— Wikipedia
把这段定义拆开看,求值策略其实在回答两个独立的问题:
- 何时求值? 实参在调用函数之前就先算出来,还是等函数真的用到它的时候再算?
- 如何传递? 算出来的那个值,是被复制一份给形参,还是让形参和实参共享同一块内存?
第一个问题分出了严格求值与非严格求值;第二个问题分出了值传递、引用传递与共享传递。Java 在两个问题上各自做了选择。
严格求值与非严格求值
考虑下面这段代码:foo(expensive())
expensive() 究竟会不会被执行?两种合理的语言设计都存在。
严格求值 (Strict Evaluation) 要求实参在函数调用前就完成求值。Java、C、Python 都属于这一类。也就是说,先算 expensive() 得到结果,再把结果传给 foo——即使 foo 内部根本没用到这个参数,这次计算也无法跳过。
非严格求值 (Non-Strict Evaluation) 则把求值推迟到形参真正被使用的那一刻。Haskell 的惰性求值 (Lazy Evaluation) 是最典型的代表:
Haskell 函数定义不写括号和 return:
foo x = 1它等价于:
def foo(x): return 1无论传入什么,结果都是 1。
foo x = 1result = foo expensive这里 expensive 永远不会被计算,因为 foo 的函数体从未引用过 x。这种机制能避免不必要的开销,也是 Haskell 能够表达无限列表的基础。
严格 / 非严格只决定了"何时算"。算出来之后怎么传给形参,是另一组规则。
参数传递的三种语义
教科书上常见的对比是两种:值传递与引用传递。但这种二分法不足以解释 Java,所以我们补上第三种——共享传递 (Call by Sharing)。
Call by Value(值传递)
实参的值被复制一份赋给形参。形参和实参从此是两块独立的存储,互不影响。void foo(int x) { x = 20;}int a = 10;foo(a);// a == 10
x 只是 a 的副本,修改 x 改不了 a。C 语言所有参数都按这种方式传递。
Call by Reference(引用传递)
形参是实参的别名——两者直接指向同一个存储单元。修改形参就等于修改实参。void foo(int& x) { x = 20;}int a = 10;foo(a);// a == 20
C++ 通过 & 显式声明引用形参,Pascal 通过 var 关键字。这种语义在 Java 中并不存在。
Call by Sharing(共享传递)
这是 Barbara Liskov 为 CLU 语言提出的术语,用来准确描述 Java、Python、Ruby、JavaScript 等语言中传递对象时的行为。
形参得到的是实参引用值的副本——也就是说:
- 实参和形参各自持有一个引用,但两个引用指向堆上同一个对象。
- 通过形参修改对象内部状态,会被外部观察到(因为是同一个对象)。
- 通过形参重新赋值(让它指向新对象),不会影响外部(因为外部那个引用没动)。
它在语义上更接近值传递(被复制的东西——引用值——不会改变),但行为上又像引用传递(能改到外部看见的对象)。这种"既不是 A 也不是 B"恰恰是争论无法收敛的根本原因。
在 Java 中看清楚
有了上面的词汇,我们来看三段代码。
基本类型:标准的值传递
public static void changePrimitive(int num) { num = 20;}int x = 10;changePrimitive(x);// x == 10int 是基本类型,num 是 x 的字面值副本,毫无悬念。
对象引用:共享传递
public static void changeReference(Integer num) { num = new Integer(20);}Integer y = new Integer(10);changeReference(y);// y 仍然指向 Integer(10)调用发生的瞬间,y 和 num 都指向堆上同一个 Integer(10):y ─────► Integer(10)num ─────┘
执行 num = new Integer(20) 后,num 改为指向一个新对象,但 y 保持不变:y ─────► Integer(10)num ─────► Integer(20)
num 持有的是 y 引用值的副本,重新赋值只改副本。这正是共享传递的定义。
数组:同一个对象的内部修改
public static void changeArray(int[] arr) { arr[0] = 20;}int[] z = {10};changeArray(z);// z[0] == 20这段代码经常被当作"Java 是引用传递"的证据,但它其实只是共享传递的自然结果:arr 和 z 是两个引用,指向同一个数组对象;arr[0] = 20 改的是对象内部的状态,不是引用本身,所以外部看得到。
作为对照,如果在函数里执行:arr = new int[]{30};
外部的 z 仍然指向原来的数组,丝毫不变——和上面 Integer 重新赋值的例子完全同构。
那"Java 只有值传递"这种说法对吗?
严格按照"什么被复制了"来回答,是对的:每次传参,Java 都在复制某个值——基本类型时复制字面值,对象时复制引用值。从未把实参和形参绑定成同一块内存。
但这种说法的代价是把"值"重新定义成"包括引用值"。对刚接触 Java 的读者来说,这个跳跃常常带来误解:他们以为"值传递"意味着对象本身也会被复制,于是被数组例子打脸后转向"引用传递",再被重新赋值的例子打回来——如此往复。
更精确的说法是:
- Java 的基本类型使用 call-by-value;
- Java 的对象使用 call-by-sharing。
这两种都属于严格求值。Java 没有 call-by-reference。
小结
- 求值策略由两个独立维度组成:何时求值(严格 / 非严格)与如何传递(按值 / 按引用 / 按共享)。
- 严格求值在调用前算完实参;惰性求值推迟到真正使用时。
- "值传递 vs 引用传递"是经典二分法,但不足以描述带引用类型的现代语言。
- Java 的对象使用 call-by-sharing:复制的是引用值,不是对象本身,也不是引用别名。
- 数组例子的"外部可见的修改"来自对象内部状态变更,而不是引用绑定——重新赋值实参永远不会影响外部。
把术语搞清楚之后,争论就没有了:Java 既不是纯粹的值传递,也不是引用传递;它是共享传递,恰好可以用"复制引用值的值传递"来描述。