“按引用传递”是什么意思?

16

那么谁有权决定呢?

编辑: 显然我没有成功地表达我的问题。
不是在问Java的参数传递如何工作。我知道看起来像保存对象的变量实际上是保存对象的引用的变量,并且该引用是按值传递的。 这个机制已经在此处(链接的线程和其他地方)和其他地方有很好的解释。

问题是关于“按引用传递”这个术语的技术含义。(结束编辑)

我不确定这是否是适合SO的问题,如果不是,我很抱歉,但我不知道更好的地方。在其他问题中已经有很多讨论了,例如Is Java "pass-by-reference" or "pass-by-value"?pass by reference or pass by value?,但我还没有找到一个权威的答案来回答这个问题。

我曾认为“按引用传递”意味着“传递对象的引用(通常是指针)”,因此被调用方可以修改调用方看到的对象,而“按值传递”则意味着复制对象,并让被调用方使用副本(明显的问题:如果对象包含引用,则需要进行深拷贝或浅拷贝)。
当提到“按引用传递”时,有很多地方都会说这意味着仅仅传递引用,很多 地方 这样说, 虽然有些争议,但定义仍然如此。在这里
一种参数传递模式,其中将实际参数的引用(或指针)传递到形式参数中;当被调用者需要形式参数时,它会解除引用指针以获取它。在this页面上,我发现“形式参数的lvalue设置为实际参数的lvalue。”,如果我理解正确,相同的定义也在here中使用(“形式参数仅充当实际参数的别名。”)。事实上,我发现唯一使用更强的定义的地方是反对Java中对象按引用传递的概念的地方(这可能是由于我的谷歌搜索能力不足)。所以,如果我理解正确,按引用传递
class Thing { ... }
void byReference(Thing object){ ... }
Thing something;
byReference(something);

根据第一定义,大致相当于(在C中)。
struct RawThing { ... };
typedef RawThing *Thing;
void byReference(Thing object){
    // do something
}
// ...
struct RawThing whatever = blah();
Thing something = &whatever;
byReference(something); // pass whatever by reference
// we can change the value of what something (the reference to whatever) points to, but not
// where something points to

在这个意义上,说Java通过引用传递对象是恰当的。但根据第二个定义,按引用传递意味着更多或更少。
struct RawThing { ... };
typedef RawThing *RawThingPtr;
typedef RawThingPtr *Thing;
void byReference(Thing object){
    // do something
}
// ...
RawThing whatever = blah();
RawThingPtr thing_pointer = &whatever;
byReference(&thing_pointer); // pass whatever by reference
// now we can not only change the pointed-to (referred) value,
// but also where thing_pointer points to

由于Java只允许您拥有指向对象的指针(限制了您可以对它们执行的操作),但没有指向指针的指针,因此,在这个意义上,说Java通过引用传递对象是完全错误的。

所以,

  1. 我是否充分理解了上述有关按引用传递的定义?
  2. 还有其他定义吗?
  3. 是否有共识认为哪个定义是“正确的”,如果有,哪个是正确的?

3
我并不主要使用Java,但我认为它不能像VB.NET的ByRef或C#的ref一样传递任何引用。我想人们所说的“按引用传递”实际上是指“按值传递引用类型”。 - Ry-
2
据我所知:Java使用传值调用。这意味着对于原始类型,值被复制到形式参数中。对于对象引用,这意味着:引用被复制到形式参数中,因此您会得到一种按引用传递的行为。 - hage
或许最清晰的表述是“所有(类类型)变量都是引用,而所有引用都是按值传递的”。你了解C++吗?这将有助于给出一个清晰的答案。 - Kerrek SB
1
请参考此答案:https://dev59.com/h2sz5IYBdhLWcg3wcnZb#7893495 - Eng.Fouad
@a1ex07 根据第一个定义。第二个例子是为了给出第二个定义的C语言对应关系。我的问题是,什么是“官方”的定义。 - Daniel Fischer
显示剩余6条评论
8个回答

8
当然,不同的人对“按引用传递”有不同的定义。这就是为什么他们在是否按引用传递上存在分歧的原因。
无论你使用哪个定义,你必须在各种语言中 一致地 运用它。你不能说一种语言采用按值传递,而在另一种语言中具有完全相同的语义,并声称它是按引用传递的。指出语言之间的类比是解决此争议的最佳方法,因为尽管人们可能对特定语言中的传递模式有强烈的看法,但当你将相同的语义与其他语言进行对比时,有时会产生违反直觉的结果,迫使他们重新思考其定义。
其中一种主导观点是Java仅采用按值传递。(在互联网上搜索,你会发现这种观点很普遍。)这种观点认为对象不是值,而是始终通过引用进行操作,因此是按值传递引用。这种观点认为,按引用传递的测试是是否可以在调用范围内分配变量。
如果你同意这个观点,那么你也必须将大多数语言(包括Python、Ruby、OCaml、Scheme、Smalltalk、SML、Go、JavaScript、Objective-C等)视为仅采用按值传递。如果其中任何一种语言的语义对你来说很奇怪或违反直觉,我挑战你指出为什么你认为它与Java中对象的语义不同。(我知道其中一些语言可能明确声称它们是按引用传递的;但是,基于实际行为,必须将一致的定义应用于所有语言,而不是根据它们所说的。)
如果你持相反的观点,认为Java中的对象是按引用传递的,那么你也必须将C视为按引用传递。
以Java示例为例:
class Thing { int x; }
void func(Thing object){ object.x = 42; object = null; }
Thing something = null;
something = new Thing();
func(something);

在C语言中,它的等价代码如下:
typedef struct { int x; } Thing;
void func(Thing *object){ object->x = 42; object = NULL; }
Thing *something = NULL;
something = malloc(sizeof Thing);
memset(something, 0, sizeof(something));
func(something);
// later:
free(something);

我认为上述内容在语义上是等价的,只是语法不同。唯一的语法差异是:
  1. C需要使用显式的*来表示指针类型;Java的引用(指向对象的指针)类型不需要显式的*
  2. C使用->通过指针访问字段;Java只需要使用.
  3. Java使用new在堆上动态分配内存以创建新对象;C使用malloc进行分配,然后我们需要初始化内存。
  4. Java具有垃圾回收机制

请注意,重要的是:

  1. 在两种情况下调用带对象的函数的语法相同:func(something),无需执行任何操作,例如取地址等。
  2. 在两种情况下,对象都是动态分配的(它可能存在于函数范围之外)。并且
  3. 在两种情况下,函数内的object = null;不会影响调用范围。

因此,在Java中调用按引用传递时,必须将C也称为按引用传递。


1
当然。我同意它们是按值传递的;我的观点是它们也是按引用传递的。(你说它们仅按值传递。)它们具有与按值传递语言相同的传递模式——函数接收其参数的值——但也具有与按引用传递语言相同的传递模式:函数接收其参数的真实别名。缺少赋值意味着这两种模式是等效的。如果 SML 的新版本(奇怪地)具有赋值,其创建者将不得不决定保留哪些语义。 - ruakh
很好的讨论,我以前从未这样考虑过Java的传递语义。 - Matt Fenwick
@ruakh:从语义上讲,ML 是按值传递的。不同的编译器对参数传递的实现方式也不同。例如,编译器可能会传递对象的 某些字段 而不是整个对象作为参数,这既不是按值传递也不是按引用传递。 - Heatsink
@ruakh,我以为你在描述ML的实现方式。如果你不是在描述语言实现,那么语义主要用于规范语言的可观察行为,并且通常会省略不必要的细节。按引用传递比按值传递更复杂,因为它需要模拟可读、可写、可寻址的内存。同样地,如果你说寄存器是语义的一部分,那么这似乎很奇怪。 - Heatsink
传值和传引用之间的一个重要概念差异在于,在传值中,接收者被允许复制和保留所传递的值,而在传引用中,接收者被禁止保留引用。如果调用fscanf(myFile,"%d",&myVar);,变量myVar本质上是通过引用传递的,因为fScanf不能保留对它的引用而不引发未定义行为。相比之下,Java引用是放荡不羁的;没有办法阻止例程保留它们。 - supercat
显示剩余5条评论

5

你的两个C语言示例实际上演示了传值调用,因为C语言没有传引用。只是你传递的值是指针。而像Perl这样的语言才会使用传引用:

sub set_to_one($)
{
    $_[0] = 1; # set argument = 1
}
my $a = 0;
set_to_one($a); # equivalent to: $a = 1

这里,变量$a实际上是通过引用传递的,因此子例程可以修改它。它不是通过间接寻址修改$a指向的某个对象; 而是修改$a本身。
在这方面,Java与C类似,只是在Java中,对象是“引用类型”,因此您拥有的(以及可以传递的)都是对它们的指针。类似于这样的内容:
void setToOne(Integer i)
{
    i = 1; // set argument = 1
}

void foo()
{
    Integer a = 0;
    setToOne(a); // has no effect
}

这段代码不会真正改变a的值,它只是重新赋值给了i


1
@Voo:你有点混淆了。在C++之外,“对___的引用”和“指向___的指针”大致等同(不同的语言使用略有不同的术语和略有不同的语义),但是C++中的引用与指针完全不同。显然,它们是使用指针实现的(可以将它们视为一层薄薄的语法糖),但是得到的语义却非常不同。除非你认为while循环和goto语句之间的区别是“政治正确性”的问题? - ruakh
2
@Voo:既然你说“请”,那我就指出Foo * ptr2Foo = NULL;是合法的,而Foo & ref2Foo = *NULL;会触发未定义行为。但实际上,你的评论毫无意义。是的,你可以重写使用引用的代码,并将其更改为使用指针;而且,这基本上就是C++编译器在内部所做的;但不,这并不意味着“按引用传递”和“按值传递,其中值是指针/引用”是同义词。语法和语义在函数及其调用者方面都是不同的。 - ruakh
@DanielFischer:啊,我明白了。在这种情况下,我认为你的方法是错误的。不同的调用约定是语言语义的问题,而不是底层实现。理论上,我可以在按值调用语言和按引用调用语言中编写程序的版本,并使用正确的编译器将它们转换为完全相同的机器代码。(由于C++允许两种调用约定,并且它的按引用调用通常很容易被转换为带指针的按值调用,因此这并不难做到。) - ruakh
关于你的例子,我同意那是有些粗糙的 - Foo *const 会更好一些,尽管我谈论的是我们可以对指针所指向的对象做什么,而不是指针本身。但是确实忘记了 const 会导致抽象泄漏,在这方面正是我们不想要的。 - Voo
@ruakh “语言语义问题” 是的,我的问题是,当人们说“按引用传递”时,他们指的是哪种语义,是第一个示例所说明的语义,还是第二个更强的语义? - Daniel Fischer
显示剩余12条评论

5

谁有权决定? 没有人,也有每个人。 您自己决定;作者为自己的书决定;读者决定是否同意作者。

要理解这个术语,需要深入了解语言(用C代码解释它们有点错过了重点)。 参数传递样式是编译器通常用于创建特定行为的机制。 通常定义以下内容:

  • 按值传递:在进入子例程时将参数复制到参数中
  • 按结果传递:在进入子例程时,参数未定义,并在子例程返回时将其复制到参数中
  • 按值结果传递:在进入时,将参数复制到参数中,并在返回时将参数复制到参数中
  • 按引用传递:将对参数变量的引用复制到参数中;对参数变量的任何访问都会被透明地转换为对参数变量的访问

(术语说明:参数是在子例程中定义的变量,参数是在调用中使用的表达式。)

教科书通常还定义了按名称传递,但这很少且不易在此解释。 还存在按需传递。

参数传递样式的重要性在于其效果:在按值传递中,对参数所做的任何更改都不会传递到参数;在按结果传递中,对参数所做的任何更改都会在最后传递给参数;在按引用传递中,对参数所做的任何更改在进行时就会传递给参数。

某些语言定义了多个传递样式,允许程序员分别为每个参数选择其首选样式。 例如,在Pascal中,默认样式为按值传递,但程序员可以使用var关键字来指定按引用传递。 还有一些其他语言指定了一种传递样式。 还有一些语言针对不同类型指定了不同的样式(例如,在C中,按值传递是默认的,但数组按引用传递)。

现在,在Java中,技术上我们拥有一种按值传递的语言,其中对象变量的值是对象的引用。 在涉及对象变量的情况下,Java是否采用按引用传递取决于个人喜好。


我认为在C语言中,数组参数会被转换成指针,并且这个指针是按值传递的。 - ninjalj
是的,这就是标准定义。但效果基本相同。 - ibid
重点在于该语言在这方面是一致的,传递值时没有例外(尽管有一些表达式会导致从数组到指针的转换,但它们都是相当合理的例外情况,例如:sizeof)。 - ninjalj

2
通过引用传递实际上是将一个值的引用作为参数传递,而不是它的副本。

在我们继续之前,需要定义一些概念。我可能会使用与您习惯的不同的方式。

  • 一个对象是数据的分子。它占用存储空间,可能包含其他对象,但具有自己的标识,并且可以作为单个单位进行引用和使用。

  • 引用是指向对象的别名或句柄。在语言层面上,引用大多数时候像其所引用的东西一样运作;根据语言,编译器/解释器/运行时/侏儒们会在需要实际对象时自动将其解引用

  • 是计算表达式的结果。它是一个具体的对象,可以存储,传递给函数等。(OOP狂热者请注意,此处我在通用“数据分子”意义上使用“对象”,而不是OOP“类的实例”意义上使用“对象”。)

  • 变量是对预分配的的命名引用

    特别注意:变量不是值。尽管名称如此,变量通常不会改变。它们的是会改变的。它们很容易混淆,部分原因在于引用<-->被引用的幻觉通常是多么好。

  • 引用类型变量(如Java、C#...)是一个变量,其是一个引用


大多数语言在将变量作为参数传递时,默认情况下会创建变量值的副本并传递该副本。调用方将其名称绑定到该参数的副本上。这称为“按值传递”(或更清楚地说,“按副本传递”)。调用前后的两个变量最终具有不同的存储位置,因此它们是完全不同的变量(只是通常具有相等的初始值)。

通过引用传递不会进行复制,而是传递变量本身(减去名称)。也就是说,它传递了一个引用到变量别名的同一个值。 (通常通过隐式传递指向变量存储的指针来完成,但这只是实现细节;调用方和被调用方不必知道或关心它发生的方式。)被调用方将其参数的名称绑定到该位置。最终结果是双方都使用相同的存储位置(可能使用不同的名称)。因此,被调用方对其变量所做的任何更改也会对调用方的变量产生影响。例如,在面向对象的语言中,可以为变量分配一个完全不同的值。
大多数语言(包括Java)不支持这种本地操作。虽然它们喜欢说他们支持...但这是因为从未真正能够实现"通过引用传递"的人,通常不理解这样做与"按值传递引用"之间微妙的区别。这些语言的混乱之处在于引用类型变量。Java本身永远不会直接使用引用类型对象,而是使用对这些对象的引用。区别在于“包含”所述对象的变量。引用类型变量的值是这样的引用(有时是表示“无”的特殊引用值)。当Java传递这样的引用时,虽然它不会复制对象,但它仍会复制值(即:函数得到的引用是变量所引用的值的副本)。也就是说,它是通过引用传递,但是通过按值传递进行传递。这允许大多数通过引用传递允许的事情,但并非全部。
最明显的真正支持按引用传递的测试我能想到的是“交换测试”。本地支持按引用传递的语言必须提供足够的支持,以编写一个交换其参数值的函数swap。代码等效于此:
swap (x, y):       <-- these should be declared as "reference to T"
  temp = x
  x = y
  y = temp

--

value1 = (any valid T)
value2 = (any other valid T)

a = value1
b = value2
swap(a, b)
assert (a == value2 and b == value1)
  1. 必须能够使用语言的赋值和严格相等运算符(包括T指定的任何重载)成功地运行于任何允许复制和重新分配的类型T。
  2. 不能要求调用者转换或“包装”参数(例如:通过显式传递指针)。要求将参数标记为按引用传递是可以的。

(显然,不具有可变变量的语言无法以这种方式进行测试——但这没关系,因为它们并不重要。两者之间的重大语义差异在于调用方的变量被被调用方修改的程度。当变量的值在任何情况下都不可修改时,差异变成了仅仅是实现细节或优化。)

请注意,本答案中大部分讨论的是“变量”。像C++这样的一些语言还允许通过引用传递匿名值。机制是相同的;值占用存储空间,引用是其别名。只是在调用方可能没有名称。


对象引用和传递引用变量之间的一个主要区别是,支持传递引用语义的语言可以传递对临时变量的引用,因为这些引用的接收者将被禁止使它们持久化。因此,如果 foo 将一个引用传递给 bar 的一个变量,那么该引用将在 bar 退出其作用域时消失,由于 bar 保证在 foo 之前退出其作用域,这确保了该引用不能超出变量的生命周期。相比之下... - supercat
Java没有提供任何好的机制,使得方法Foo可以传递一个对Bar的引用,而不必担心Bar在某个地方存储了该引用的副本。Foo唯一能做的就是构造一个包装对象,将该包装对象的引用传递给Bar,然后在Bar退出后使包装对象失效。这种方法可能可行,但非常笨拙。 - supercat
事实上,在C++中,存储在std::reference_wrapper中的引用或指向所引用变量的指针是可以半轻松地持久化的,尽管这样做可能会触发UB。 - cHao
不同支持按引用传递的语言和框架在防止方法不正确地保留传入变量的引用方面有所不同,但一个共同点是,在超出作用域后仍使用捕获的引用的任何代码都要自己承担风险。 - supercat
如果调用者被允许在返回后的任意时间使用引用,则我认为这是按值传递的引用。如果被调用者允许传递临时引用,则我认为该引用的目标是按引用传递的。混合设计是可能的,其中方法有时会保留传递的引用,而调用者有时会传递临时引用;这样的设计不需要调用未定义行为,但最好还是避免使用。 - supercat
显示剩余2条评论

1
Java 不是按引用传递的。你总是传递一个复制品/按值传递。但是,如果你传递一个对象,则会得到一个引用的副本。所以你可以直接编辑这个对象,但是如果你覆盖了本地引用,那么原始对象引用不会被覆盖。

1

维基百科对按引用调用(call-by-reference)的定义非常清晰,我无法改进:

在按引用调用(也称为传递引用),函数接收一个隐式引用作为参数使用的变量,而不是其值的副本。这通常意味着函数可以修改(即赋值给)用作参数的变量-这将被其调用者看到。

请注意,您的两个示例都不是按引用调用,因为在C中分配形式参数从不修改调用者所看到的参数。

但是,这已经足够了,阅读详细讨论(附有示例):

http://en.wikipedia.org/wiki/Evaluation_strategy#Call_by_reference


但是newacct的例子会是“按引用传递”的吗? - Sam Ginrich

0
如果您熟悉C语言,也许以下类比可以解释Java的工作原理。这仅适用于类类型的对象(而不是基本类型)。
在Java中,我们可以拥有一个变量并将其传递给函数:
void f(Object x)
{
  x.bar = 5;    // #1j
  x = new Foo;  // #2j
}

void main()
{
  Foo a;
  a.bar = 4;
  f(a);
  // now a.bar == 5
}

在C语言中,这将如下所示:
void f(struct Foo * p)
{
  p->bar = 5;                      // #1c
  p = malloc(sizeof(struct Foo));  // #2c
}

int main()
{
  struct Foo * w = malloc(sizeof(struct Foo));
  w->bar = 4;
  f(w);
  /* now w->bar = 5; */
}

在Java中,类类型的变量始终是“引用”,在C中最忠实地映射为“指针”。但在函数调用中,指针本身是通过副本传递的。像#1j和#1c中访问指针会修改原始变量,因此从这个意义上说,您正在传递对变量的引用。但是,变量本身只是一个指针,并且它本身是通过副本传递的。因此,当您将其他内容分配给它时,例如#2j和#2c中,您只是重新绑定了f局部范围内引用/指针的副本。原始变量,在各自的示例中为aw,保持不变。
简而言之:一切都是引用,并且引用按值传递。
另一方面,在C中,我可以通过声明void v(struct Foo ** r);并调用f(&w)来实现真正的“按引用传递”,这将允许我从f内部更改w本身。
注意1:对于像int这样的基本类型,这并不正确,它们完全按值传递。
注意2:如果我能通过引用传递指针(而且不必使用struct),C++示例会更加整洁:void f(Foo * & r) { r = new Foo; },然后调用f(w);

像往常一样,您的回答非常好。不幸的是,这个答案并不是我想问的问题的答案。我很抱歉因为不清楚而浪费了您的时间。 - Daniel Fischer
没问题。还有什么需要解释的,其他答案没有涉及到的吗? - Kerrek SB
这取决于第三点的答案。如果有共识(指大多数计算机科学家都同意一个含义,就像绝大多数数学家都同意“质数”的含义一样),我仍然想知道是哪个含义。如果答案是“很多人这么说,很多人那么说”,仍然很高兴得到确认,但这是我目前的印象。 - Daniel Fischer
没错。我自己也不知道是否存在一个明确的答案,但抽象概念应该是相当清晰的(按引用传递:在函数中更改函数参数会更改调用站点上的原始对象)。实现按引用传递语义对于每种语言都是具体的;例如,在C中,您可以通过添加一层地址/传递指针来实现它,而C++具有本地引用类型。我想你应该将抽象概念和特定于语言的实现分开考虑。 - Kerrek SB
Java实现了对象的“按引用传递”语义,但对于变量则不是:由于Java没有“引用”类型修饰符,因此您无法通过引用再次传递变量(它们始终是引用类型本身),并通过函数调用重新绑定它们。 - Kerrek SB

0

通过引用传递参数意味着参数的指针嵌套比本地变量的指针嵌套更深。如果您有一个类型为类的变量,则该变量是指向实际值的指针。原始类型的变量包含值本身。

现在,如果您按值传递这些变量,则保留指针嵌套:对象引用仍然是指向对象的指针,而原始变量仍然是值本身。

将变量作为引用传递意味着指针嵌套变得更深:您传递一个指向对象引用的指针,以便可以更改对象引用;或者您传递一个指向原始变量的指针,以便可以更改其值。

这些定义在C#和Object Pascal中使用,两者都具有关键字以通过引用传递变量。

回答您的问题:因为最后一个变量 - 第一个示例中的whatever和第二个示例中的thing_pointer - 都通过指针(&)传递到函数中,因此两者都是通过引用传递的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接