﻿---
title: 编程语言中的求值策略
date: 2024-02-01
excerpt: "为什么 Java 既不是\"纯粹的值传递\"也不是\"引用传递\"？答案藏在 call-by-sharing 这个常被忽略的术语里。"
tags:
  - Java
  - Programming
lang: zh-CN
i18n:
  en: /en/java_evaluation
translation: 2
updated: 2026-06-06 22:12:32
---

<script type="module" src="/js/components/sidenote.js"></script>

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

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

## 形参与实参

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

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

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

## 求值策略是什么

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

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

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

第一个问题分出了严格求值与非严格求值；第二个问题分出了值传递、引用传递与共享传递。Java 在两个问题上各自做了选择。

## 严格求值与非严格求值

考虑下面这段代码：

```python
foo(expensive())
```

`expensive()` 究竟会不会被执行？两种合理的语言设计都存在。

**严格求值 (Strict Evaluation)** 要求实参在函数调用前就完成求值。Java、C、Python 都属于这一类。也就是说，先算 `expensive()` 得到结果，再把结果传给 `foo`——即使 `foo` 内部根本没用到这个参数，这次计算也无法跳过。

**非严格求值 (Non-Strict Evaluation)** 则把求值推迟到形参真正被使用的那一刻。Haskell 的惰性求值 (Lazy Evaluation) 是最典型的代表：

<side-note>

Haskell 函数定义不写括号和 return：

```haskell
foo x = 1
```

它等价于：

```py
def foo(x):
    return 1
```

无论传入什么，结果都是 1。

</side-note>

```haskell
foo x = 1
result = foo expensive
```

这里 `expensive` 永远不会被计算，因为 `foo` 的函数体从未引用过 `x`。这种机制能避免不必要的开销，也是 Haskell 能够表达无限列表的基础。

严格 / 非严格只决定了"何时算"。算出来之后怎么传给形参，是另一组规则。

## 参数传递的三种语义

教科书上常见的对比是两种：值传递与引用传递。但这种二分法不足以解释 Java，所以我们补上第三种——**共享传递 (Call by Sharing)**。

### Call by Value（值传递）

实参的值被**复制**一份赋给形参。形参和实参从此是两块独立的存储，互不影响。

```c
void foo(int x) {
    x = 20;
}

int a = 10;
foo(a);
// a == 10
```

`x` 只是 `a` 的副本，修改 `x` 改不了 `a`。C 语言所有参数都按这种方式传递。

### Call by Reference（引用传递）

形参是实参的**别名**——两者直接指向同一个存储单元。修改形参就等于修改实参。

```cpp
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 中看清楚

有了上面的词汇，我们来看三段代码。

### 基本类型：标准的值传递

```java
public static void changePrimitive(int num) {
    num = 20;
}

int x = 10;
changePrimitive(x);
// x == 10
```

`int` 是基本类型，`num` 是 `x` 的字面值副本，毫无悬念。

### 对象引用：共享传递

```java
public static void changeReference(Integer num) {
    num = new Integer(20);
}

Integer y = new Integer(10);
changeReference(y);
// y 仍然指向 Integer(10)
```

调用发生的瞬间，`y` 和 `num` 都指向堆上同一个 `Integer(10)`：

```text
y   ─────► Integer(10)
num ─────┘
```

执行 `num = new Integer(20)` 后，`num` 改为指向一个新对象，但 `y` 保持不变：

```text
y   ─────► Integer(10)
num ─────► Integer(20)
```

`num` 持有的是 `y` 引用值的副本，重新赋值只改副本。这正是共享传递的定义。

### 数组：同一个对象的内部修改

```java
public static void changeArray(int[] arr) {
    arr[0] = 20;
}

int[] z = {10};
changeArray(z);
// z[0] == 20
```

这段代码经常被当作"Java 是引用传递"的证据，但它其实只是共享传递的自然结果：`arr` 和 `z` 是两个引用，指向同一个数组对象；`arr[0] = 20` 改的是**对象内部**的状态，不是引用本身，所以外部看得到。

作为对照，如果在函数里执行：

```java
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 既不是纯粹的值传递，也不是引用传递；它是共享传递，恰好可以用"复制引用值的值传递"来描述。
