编程语言中的求值策略

为什么 Java 既不是"纯粹的值传递"也不是"引用传递"?答案藏在 call-by-sharing 这个常被忽略的术语里。

每隔一段时间,关于"Java 到底是值传递还是引用传递"的争论就会在论坛上重新点燃。一方拿出修改对象属性会影响外部的例子,宣称是引用传递;另一方拿出重新赋值不会影响外部的例子,反驳说是值传递。两边都能找到自圆其说的代码,谁也说服不了谁。

要把这件事说清楚,需要先从一个更上层的概念入手——求值策略 (Evaluation Strategy)。Java 的参数传递行为只是它求值策略的一个侧面;当我们有了完整的词汇表,"值传递还是引用传递"这个二元问题本身就消失了。

形参与实参

在开始之前,先固定两个会反复用到的术语:

  • 形参 (Parameter):函数定义时声明的参数,用于在函数体内接收传入的值。def foo(a: int) 中的 a 就是形参。
  • 实参 (Argument):函数调用时实际写在括号里的表达式。y = foo(x) 中的 x 就是实参。

求值策略关心的,正是实参如何被求值,以及它的值如何与形参建立联系。

求值策略是什么

在计算机科学中,求值策略是确定编程语言中表达式如何求值的一组规则。重点典型地位于函数或算子上——求值策略定义何时、以何种次序求值给函数的实际参数,什么时候把它们代换入函数,以及代换以何种形式发生。

Wikipedia

把这段定义拆开看,求值策略其实在回答两个独立的问题:

  1. 何时求值? 实参在调用函数之前就先算出来,还是等函数真的用到它的时候再算?
  2. 如何传递? 算出来的那个值,是被复制一份给形参,还是让形参和实参共享同一块内存?

第一个问题分出了严格求值与非严格求值;第二个问题分出了值传递、引用传递与共享传递。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 == 10

int 是基本类型,numx 的字面值副本,毫无悬念。

对象引用:共享传递

public static void changeReference(Integer num) {    num = new Integer(20);}Integer y = new Integer(10);changeReference(y);// y 仍然指向 Integer(10)

调用发生的瞬间,ynum 都指向堆上同一个 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 是引用传递"的证据,但它其实只是共享传递的自然结果:arrz 是两个引用,指向同一个数组对象;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 既不是纯粹的值传递,也不是引用传递;它是共享传递,恰好可以用"复制引用值的值传递"来描述。

CompactRelaxed
Normal1.70