我一直认为Java使用的是按引用传递。然而,我看了一篇博客文章,声称Java使用的是按值传递。我不认为我理解作者所做的区分。
这是什么意思?
我一直认为Java使用的是按引用传递。然而,我看了一篇博客文章,声称Java使用的是按值传递。我不认为我理解作者所做的区分。
这是什么意思?
我很惊讶没有人提到巴巴拉·利斯科夫 (Barbara Liskov)。1974 年她设计 CLU 时,遇到了类似的术语问题,她为值为引用的“按值调用”这种特定情况创造了术语 共享调用(也称为 对象共享调用 和 对象调用)。
问题的关键在于,“按引用传递”表达式中的单词“reference”,其意义与Java中单词“reference”通常的含义完全不同。
通常情况下,在Java中,“reference”指的是一个对象的引用。但是,编程语言理论中的技术术语“按引用/按值传递”所谈论的是一个引用内存单元的变量的引用,这是完全不同的东西。
已经有很好的答案涵盖了这个问题。我想通过分享一个非常简单的例子(可以编译)来做出小贡献,以对比C ++中的按引用传递和Java中的按值传递的行为。
几点说明:
C++按引用传递示例:
using namespace std;
#include <iostream>
void change (char *&str){ // the '&' makes this a reference parameter
str = NULL;
}
int main()
{
char *str = "not Null";
change(str);
cout<<"str is " << str; // ==>str is <null>
}
Java按值传递“Java引用”示例
public class ValueDemo{
public void change (String str){
str = null;
}
public static void main(String []args){
ValueDemo vd = new ValueDemo();
String str = "not null";
vd.change(str);
System.out.println("str is " + str); // ==> str is not null!!
// Note that if "str" was
// passed-by-reference, it
// WOULD BE NULL after the
// call to change().
}
}
编辑
有几个人写了评论,似乎表明他们要么没有看我的示例,要么不理解c++的示例。不确定出现问题的地方在哪里,但猜想c++的示例不清楚。我将在Pascal中发布相同的示例,因为我认为按引用传递在Pascal中看起来更整洁,但我可能是错的。我可能只会使人更加困惑;希望不是这样。
在Pascal中,通过引用传递的参数称为“var参数”。请注意setToNil过程下面的关键字“var”,它前面是参数“ptr”。当指针传递到此过程时,它将被传递按引用。请注意行为:当此过程将ptr设置为nil(这是Pascal中的NULL),它将把参数设置为nil--您不能在Java中这样做。
program passByRefDemo;
type
iptr = ^integer;
var
ptr: iptr;
procedure setToNil(var ptr : iptr);
begin
ptr := nil;
end;
begin
new(ptr);
ptr^ := 10;
setToNil(ptr);
if (ptr = nil) then
writeln('ptr seems to be nil'); { ptr should be nil, so this line will run. }
end.
编辑2
以下摘自Ken Arnold、Java创始人James Gosling以及David Holmes所写的《Java编程语言》第2章第2.6.5节。
所有方法参数都是通过“值传递”传递的。换句话说,在方法中,参数变量的值是调用者指定的参数的副本。
他还就对象作出了同样的论点……
需要注意的是,当参数为对象引用时,传递的是对象引用,而不是对象本身,因为它是按值传递的。
在同一部分的末尾,他对Java仅使用值传递而从不使用引用传递做出了更广泛的陈述。
Java编程语言不会通过引用传递对象,而是通过值传递对象引用。由于两个相同引用的副本指向同一个实际对象,通过一个引用变量进行的更改可以通过另一个引用变量观察到。只有一种参数传递模式-按值传递,这有助于让事情保持简单。
该书的这一部分对Java中的参数传递和值传递与引用传递的区别进行了很好的解释,而且这还是Java的创始人所写。我鼓励任何人都去阅读一下,特别是如果你仍然不确定的话。
我认为两种模型之间的差异非常微妙,除非你有使用过按引用传递的编程经验,否则很容易错过它们之间的区别。
希望这能解决争论,但可能不会。
编辑3
我或许对这篇文章有点痴迷。也许是因为我觉得Java的创造者们无意中散布了错误信息。如果他们没有使用“引用”这个词来代替指针,而是使用其他词汇,比如dingleberry,那么就不会有问题。你可以说,“Java按值传递dingleberries而不是按引用传递”,这样就不会引起混淆了。
这就是为什么只有Java开发人员会遇到这个问题。他们看到“引用”这个词,就认为自己完全知道它的含义了,所以甚至不考虑反对的论点。
无论如何,我在早期的帖子评论中注意到了一个气球类比,我非常喜欢。因此,我决定将一些剪贴画粘在一起,制作一组漫画来说明这一点。
按值传递引用--对引用的更改不会反映在调用者的范围内,但对象的更改会反映出来。这是因为引用被复制了,但原始引用和副本都指向同一个对象。
按引用传递--没有引用的副本。单个引用由调用者和被调用的函数共享。对引用或对象数据的任何更改都会反映在调用者的范围内。
编辑4
我看到过有关此主题的文章描述了Java中参数传递的底层实现,我认为这很棒,非常有帮助,因为它使抽象的想法具体化了。但是,对我来说,问题更多地涉及语言规范中描述的行为,而不是行为的技术实现。这是Java语言规范第8.4.1节的摘录:
当调用方法或构造函数(§15.12)时,实际参数表达式的值会初始化新创建的参数变量,每个声明类型,在执行方法或构造函数体之前。在DeclaratorId中出现的标识符可以在方法或构造函数体中用作简单名称,以引用形式参数。
这意味着,在执行方法之前,Java会创建传递参数的副本。像大多数学习编译器的人一样,我使用了Dragon Book,它是编译器书籍的经典之作。第1章中对“按值调用”和“按引用调用”的描述与Java规范完全匹配。
回到我上学时学习编译器的时候,90年代我使用的是1986年出版的第一版书,早于Java约9或10年。然而,我刚刚找到了2007年出版的第二版,其中实际提到了Java!第1.6.6节标为“参数传递机制”的描述了参数传递的情况。以下是标题为“按值调用”的摘录,其中提到了Java:
在值调用中,实参被评估(如果它是一个表达式)或复制(如果它是一个变量)。该值被放置在被调用过程的相应形式参数所属的位置。这种方法在C和Java中被使用,并且在C ++中是一种常见选项,以及大多数其他语言中也是如此。该方法被广泛应用于IT技术领域。public class PassByValue {
public static void main(String[] args) {
Test t = new Test();
t.name = "initialvalue";
new PassByValue().changeValue(t);
System.out.println(t.name);
}
public void changeValue(Test f) {
f.name = "changevalue";
}
}
class Test {
String name;
}
让我们一步一步来理解:changevalue
Test t = new Test();
0x100234
(我们不知道实际的JVM内部值,这只是一个例子)。
new PassByValue().changeValue(t);
public class PassByValue {
public static void main(String[] args) {
Test t = new Test();
t.name = "initialvalue";
new PassByValue().changeRefence(t);
System.out.println(t.name);
}
public void changeRefence(Test f) {
f = null;
}
}
class Test {
String name;
}
在Java中,一切都是引用。因此,当你有像这样的东西时:
Point pnt1 = new Point(0,0);
Java会执行以下操作:
Java不会按引用传递方法参数;它会按值传递。我将使用这个网站上的示例:
public static void tricky(Point arg1, Point arg2) {
arg1.x = 100;
arg1.y = 100;
Point temp = arg1;
arg1 = arg2;
arg2 = temp;
}
public static void main(String [] args) {
Point pnt1 = new Point(0,0);
Point pnt2 = new Point(0,0);
System.out.println("X1: " + pnt1.x + " Y1: " +pnt1.y);
System.out.println("X2: " + pnt2.x + " Y2: " +pnt2.y);
System.out.println(" ");
tricky(pnt1,pnt2);
System.out.println("X1: " + pnt1.x + " Y1:" + pnt1.y);
System.out.println("X2: " + pnt2.x + " Y2: " +pnt2.y);
}
程序的流程:
Point pnt1 = new Point(0,0);
Point pnt2 = new Point(0,0);
创建两个不同的点对象,分别关联两个不同的引用。
System.out.println("X1: " + pnt1.x + " Y1: " +pnt1.y);
System.out.println("X2: " + pnt2.x + " Y2: " +pnt2.y);
System.out.println(" ");
正如预期的输出:
X1: 0 Y1: 0
X2: 0 Y2: 0
在这行代码中,'pass-by-value' 开始发挥作用...
tricky(pnt1,pnt2); public void tricky(Point arg1, Point arg2);
引用 pnt1
和 pnt2
作为参数传递给了一个名为“tricky”的方法,这意味着现在你的引用 pnt1
和 pnt2
有了它们的副本,分别被命名为 arg1
和 arg2
。因此,pnt1
和 arg1
指向 同一个对象。(pnt2
和 arg2
同理)
在 tricky
方法中:
arg1.x = 100;
arg1.y = 100;
接下来是 tricky
方法。
Point temp = arg1;
arg1 = arg2;
arg2 = temp;
在这里,首先创建一个新的temp
Point引用,它将指向与arg1
引用相同的位置。然后将引用arg1
移动到与arg2
引用相同的位置。
最后,arg2
将指向与temp
相同的位置。
从这里开始,tricky
方法的作用域消失了,并且您不能再访问三个引用:arg1
、arg2
和temp
。但重要的是,在这些引用仍然“活着”的时候,对它们所指向的对象进行的任何操作都将永久性地影响该对象。
因此,在执行tricky
方法后,当您返回到main
方法时,您将面临以下情况:
因此,程序的完整执行如下:
X1: 0 Y1: 0
X2: 0 Y2: 0
X1: 100 Y1: 100
X2: 0 Y2: 0
从一个非常具有创意的角度来看,让我们来看看汇编语言或一些低级内存管理。在CPU层面上,对于任何东西的引用,如果将其写入内存或写入CPU寄存器之一,它立马就变成了一个值。(这就是为什么“指针”是一个很好的定义。它既是一个值,同时也有一个目的)。
内存中的数据具有一个位置,并且在那个位置有一个值(字节,单词或其他)。在汇编语言中,我们可以方便地给某个位置(也称为变量)分配一个名称,但在编译代码时,汇编器会将名称简单地替换为指定的位置,就像你的浏览器会将域名替换为IP地址一样。
从本质上说,在任何语言中,不经过表示地传递引用是技术上不可能的(因为它们立即就变成了值)。
假设我们有一个变量Foo,它的位置在内存的第47个字节,它的值为5。我们还有另一个变量Ref2Foo,它位于内存的第223个字节,并且它的值是47。这个Ref2Foo可能是一个技术性变量,不是程序显式创建的。如果你只看到5和47而没有其他信息,你将只看到两个值。如果你将它们用作引用,那么要到达5
,我们必须经过以下步骤:
(Name)[Location] -> [Value at the Location]
---------------------
(Ref2Foo)[223] -> 47
(Foo)[47] -> 5
这是跳转表的工作原理。
如果我们想使用Foo的值调用一个方法/函数/过程,那么根据语言及其几种方法调用模式,有几种可能的方式将变量传递给该方法:
在上述每种情况下,都创建了一个值-现有值的副本,现在由接收方法处理它。当您在方法内部写入“Foo”时,它将从EAX中读取出来,或者自动进行取消引用或双重取消引用,该过程取决于语言的工作方式和/或Foo的类型所决定的。直到开发人员绕过取消引用过程之前,这对开发人员而言是隐藏的。因此,在表示时,引用是一个值,因为引用是必须在语言级别上进行处理的值。
现在我们已经将Foo传递给方法:
Foo = 9
),它仅影响局部范围,因为您复制了该值。从方法内部,我们甚至无法确定原始Foo在内存中的位置。Foo = 11
),它可能会全局更改Foo(这取决于语言,例如Java或类似Pascal的procedure findMin(x、y、z: integer;
var m: integer);
)。然而,如果语言允许您绕过取消引用过程,则可以更改47
,比如说要更改为49
。此时,如果您读取它,则似乎已更改Foo,因为您已将本地指针更改为它。如果您在方法内部修改此Foo(Foo = 12
),则可能会破坏程序的执行(称为segfault),因为您将写入不同于预期的内存区域,甚至可以修改注定要保存可执行程序的区域,并且写入它将修改正在运行的代码(Foo现在不在47
)。但是,Foo的值47
没有全局更改,仅在方法内部更改,因为47
也是一份复制品交给了该方法。223
,则会创建与3或4中相同的混乱(指向现在错误值的指针,再次用作指针),但这仍然是一个本地问题,因为223被复制了。但是,如果您能够取消引用Ref2Foo
(即223
),达到并修改所指向的值47
,比如说要更改为49
,它将对Foo产生全局影响,因为在这种情况下,方法得到了223
的一份副本,但所引用的47</
在Java中, 方法参数都是按值传递的:
Java的参数都是按值传递的(当方法使用值或引用时,该值或引用被复制):
对于原始类型,Java的行为很简单: 该值会被复制到原始类型的另一个实例中。
对于对象而言,也是一样的: 对象变量是引用(只包含对象的地址的内存桶,而不是原始值),使用“new”关键字创建,并像原始类型一样被复制。
这种行为可能与原始类型不同:因为复制的对象变量包含相同的地址(指向相同的对象)。 对象的内容/成员仍然可以在方法内进行修改,并在稍后访问外部,使人产生所包含的对象本身是按引用传递的错觉。
“String”对象似乎是反驳谣言“对象是按引用传递”的一个很好的反例:
实际上,在使用方法时,您将永远无法更新作为参数传递的字符串的值:
一个String对象,通过一个声明为final的数组来保存字符,该数组不能被修改。 只有使用“new”替换对象的地址时,才能更新变量,但由于变量最初是按值传递和复制的,因此无法从外部访问该对象。
public static void swap(StringBuffer s1, StringBuffer s2) {
StringBuffer temp = s1;
s1 = s2;
s2 = temp;
}
public static void main(String[] args) {
StringBuffer s1 = new StringBuffer("Hello");
StringBuffer s2 = new StringBuffer("World");
swap(s1, s2);
System.out.println(s1);
System.out.println(s2);
}
这将填充“Hello World”而不是“World Hello”,因为在交换函数中,您使用的副本对主函数中的引用没有影响。但是,如果您的对象不是不可变的,您可以进行更改,例如:
public static void appendWorld(StringBuffer s1) {
s1.append(" World");
}
public static void main(String[] args) {
StringBuffer s = new StringBuffer("Hello");
appendWorld(s);
System.out.println(s);
}
这将在命令行上显示“Hello World”。如果你把StringBuffer改为String,它只会生成“Hello”,因为String是不可变的。例如:
public static void appendWorld(String s){
s = s+" World";
}
public static void main(String[] args) {
String s = new String("Hello");
appendWorld(s);
System.out.println(s);
}
不过,您可以创建一个类似这样的 String 包装器,它可以使其能够与 String 一起使用:
class StringWrapper {
public String value;
public StringWrapper(String value) {
this.value = value;
}
}
public static void appendWorld(StringWrapper s){
s.value = s.value +" World";
}
public static void main(String[] args) {
StringWrapper s = new StringWrapper("Hello");
appendWorld(s);
System.out.println(s.value);
}
编辑:我认为这也是在处理“添加”两个字符串时使用StringBuffer的原因,因为您可以修改原始对象,而对于像字符串这样的不可变对象则无法修改。
不是引用传递。
根据Java语言规范,Java采用按值传递:
当方法或构造函数被调用(§15.12)时,“实际参数表达式的值将初始化新创建的参数变量”,它们均为已声明的类型,在执行方法或构造函数体之前。在方法或构造函数的主体中,可以使用出现在DeclaratorId中的标识符作为简单名称来引用形式参数。