Java中按值传递与按引用传递


对于Java开发人员,尤其是那些习惯于使用更冗长的编程语言(例如C或C ++)的Java开发人员而言,了解用于在变量之间传递信息并传递给方法的技术可能是一项艰巨的任务。在这些表达性语言中,开发人员全权负责确定用于在系统的不同部分之间传递信息的技术。例如,C ++允许开发人员按值,按引用或按指针显式传递一条数据。编译器仅确保所选技术已正确实现,并且没有执行无效操作。

在Java的情况下,这些低级细节被抽象化了,这既减少了开发人员选择传递数据的适当方式的负担,又提高了语言的安全性(通过禁止操作指针和直接寻址内存)。但是,此外,这种抽象级别隐藏了所执行技术的细节,这可能会使开发人员对程序中如何传递数据的理解模糊不清。在本文中,我们将研究用于传递数据的各种技术,并深入研究Java虚拟机(JVM)和Java编程语言用于传递数据的技术,并探索一些实际使用的示例。这些技术对开发人员意味着什么。

术语

通常,有两种主要的技术可以以编程语言传递数据:(1)通过值传递和(2)通过引用传递。虽然某些语言考虑通过引用传递和通过指针传递两种不同的技术,但从理论上讲,一种技术可以认为是另一种技术的一种特殊化,其中引用只是对象的别名,该对象的实现是指针。

价值传递

按值传递的第一种技术定义如下:

按值传递构成数据复制,其中复制值的更改不会反映在原始值中 例如,如果我们调用一个接受单个整数参数的方法,并且该方法对该参数进行了赋值,则该方法返回后将不保留该赋值。尽管可能希望在方法返回后保留该赋值,但是该赋值会丢失,因为放置在调用堆栈上的值是传递给该方法的值的副本,如下面的代码段所示:

#include <iostream>

using namespace std;

void process(int value) {
    cout << "Value passed into function: " << value << endl;
    value = 10;
    cout << "Value before leaving function: " << value << endl;
}

int main() {
    int someValue = 7;
    cout << "Value before function call: " << someValue << endl;
    process(someValue);
    cout << "Value after function call: " << someValue << endl;

    return 0;
}

如果执行此代码,则将获得以下输出:

Value before function call: 7                                                              
Value passed into function: 7                                                              
Value before leaving function: 10                                                          
Value after function call: 7

我们看到退出函数范围后,对传递给process函数的参数所做的更改未保留。数据丢失的原因someValue是,在执行process函数之前,将变量保存的值的副本放置在调用堆栈上。一旦process函数退出,这个副本是从调用栈中弹出,并且它所做的更改丢失,如下图所示:

pass-by-value.png

此外,process下图说明了在方法完成时弹出调用堆栈的操作。请注意,process一旦弹出调用堆栈,作为该方法的参数复制的值就会丢失(回收),因此,对该值所做的所有更改都会在回收步骤中丢失。

popping-call-stack.png

通过参考

按值传递的替代方法是按引用传递,其定义如下:

通过引用传递将构成数据的别名,其中别名值的更改会反映在原始值中 与按值传递不同,按引用传递可确保在退出该范围时保留对不同范围中的值所做的更改。例如,如果我们通过引用将单个参数传递给方法,并且该方法在其主体内对该值进行赋值,则在方法退出时将保留该赋值。可以使用以下C ++代码片段来证明这一点:

#include <iostream>

using namespace std;

void process(int& value) {
    cout << "Value passed into function: " << value << endl;
    value = 10;
    cout << "Value before leaving function: " << value << endl;
}

int main() {
    int someValue = 7;
    cout << "Value before function call: " << someValue << endl;
    process(someValue);
    cout << "Value after function call: " << someValue << endl;

    return 0;
}

如果运行此代码,则将获得以下输出:

Value before function call: 7                                                                                                                                                    
Value passed into function: 7                                                                                                                                                    
Value before leaving function: 10                                                                                                                                               
Value after function call: 10

在此示例中,我们可以看到,当退出函数时,对通过引用传递的自变量所做的赋值保留在函数范围之外。对于C ++,我们可以发现,在后台,编译器已将指针传递给指向someValue变量的函数。因此,当该指针被取消引用时(如在重新分配期间发生的情况),我们将更改存储该someValue变量的内存中的确切位置。下图说明了这一原理:

pass-by-reference.png

用Java传递数据

与C ++不同,Java没有办法明确区分按引用传递和按值传递。相反,Java语言规范(第4.3节)声明通过以下规则定义所有数据(对象和原始数据)的传递:

所有数据均按值传递 尽管此规则从表面上看可能很简单,但仍需要进一步说明。在原始值的情况下,该值是简单地与原语(.eg相关联的实际数据1,20.7,true等),并且它被传递的每个时间被复制的数据的值。例如,如果我们定义诸如的表达式int x = 7,则变量x将保留的字面值7。对于Java中的对象,将使用更扩展的规则:

与对象关联的值实际上是指向内存中对象的指针,称为引用 例如,如果我们定义一个表达式,例如Foo foo = new Foo(),则该变量foo不保存Foo创建的对象,而是保存指向创建的Foo对象的指针值。每次传递该对象的指针(Java规范称为对象引用,或简称为引用)时,都会复制该指针的值。根据Java语言规范的“对象”部分(第4.3.1节),只能对对象引用执行以下操作:

  • 使用限定名或字段访问表达式进行字段访问
  • 方法调用
  • The cast operator
  • 字符串串联运算符 +,当给定一个 String操作数和一个引用时,它将String 通过调用toString 被引用对象的 方法将引用转换为a ( "null"如果引用或结果 toString 为空引用,则使用),然后产生一个新创建的 String 是两个字符串的串联
  • instanceof运营商
  • 参考相等运算符==!=
  • 条件运算符 ? :

实际上,这意味着我们可以更改传递到方法中的对象的字段并调用其方法,但是我们不能更改引用指向的对象。由于指针是通过值传递到方法中的,因此在调用方法时,原始指针将被复制到调用堆栈中。退出方法范围时,复制的指针将丢失,从而丢失对指针值的更改。

尽管丢失了指针,但由于我们正在取消对指针的访问以访问指向的对象,因此保留了对字段的更改:传递给方法的指针和复制到调用堆栈的指针是相同的(尽管独立),因此指向到同一个对象。因此,当取消引用指针时,将访问内存中相同位置的相同对象。因此,当我们对取消引用的对象进行更改时,我们正在更改共享对象。下图说明了这个概念:

java-pass-by-value.png

这不应与按引用传递混淆:如果指针是按引用传递的,则变量foo将是someFoo该对象的别名,并且更改foo指向的对象也将更改someFoo指向的对象。但是,在这种情况下,将复制的指针传递到函数中,因此,一旦弹出的调用堆栈,对指针值的更改就会丢失。

例子 虽然了解以编程语言(尤其是Java)传递数据的概念至关重要,但很多时候,如果没有具体示例,很难巩固这些理论思想。在本节中,我们将介绍四个主要示例:

  1. 将原始值分配给变量
  2. 将原始值传递给方法
  3. 将对象值分配给变量
  4. 将对象值传递给方法

对于这些示例中的每一个,我们将探索代码片段以及打印语句,这些语句显示赋值或参数传递过程中每个主要步骤的基元或对象的值。

基本类型示例 由于Java原语不是对象,因此就数据绑定(赋值)和参数传递而言,原语和对象被视为两种单独的情况。在本节中,我们将专注于将原始数据绑定到变量并将原始数据传递给简单方法。

给变量赋值 如果我们将现有的原始值(例如)分配给someValue新变量anotherValue,则原始值将被复制到新变量。由于复制了值,因此两个变量不是彼此的别名,因此,当someValue更改原始变量时,该更改不会反映在anotherValue:

int someValue = 10;
int anotherValue = someValue;
someValue = 17;
System.out.println("Some value = " + someValue);
System.out.println("Another value = " + anotherValue);

如果执行此代码段,则会收到以下输出:

Some value = 17
Another value = 10

将值传递给方法 与进行原始分配类似,方法的参数受值限制,因此,如果在方法范围内对参数进行了更改,则退出方法范围时,更改将不会保留:

public void process(int value) {
    System.out.println("Entered method (value = " + value + ")");
    value = 50;
    System.out.println("Changed value within method (value = " + value + ")");
    System.out.println("Leaving method (value = " + value + ")");
}

PrimitiveProcessor processor = new PrimitiveProcessor();
int someValue = 7;
System.out.println("Before calling method (value = " + someValue + ")");
processor.process(someValue);
System.out.println("After calling method (value = " + someValue + ")");

如果运行此代码,我们将看到7退出process方法范围时仍保留的原始值,即使该参数已50在方法范围内指定了值:

Before calling method (value = 7)
Entered method (value = 7)
Changed value within method (value = 50)
Leaving method (value = 50)
After calling method (value = 7)

对象类型示例 在Java中,虽然所有值(原始值和对象值)都按值传递,但在示例中看到时,按值传递对象有一些细微差别。与原始类型一样,下面的示例将探讨赋值和参数绑定。

给变量赋值 对象和基元的变量绑定语义几乎相同,但是我们绑定了对象地址的副本,而不是绑定基元值的副本。我们可以在以下代码片段中看到这一点:

public class Ball {}

Ball someBall = new Ball();
System.out.println("Some ball before creating another ball = " + someBall);
Ball anotherBall = someBall;
someBall = new Ball();
System.out.println("Some ball = " + someBall);

在这个例子中,我们预计分配一个新的Ball对象someBall(后分配someBall到anotherBall)并没有改变的价值anotherBall,因为anotherBall保存了原地址的副本someBall。当存储的地址someBall更改时,不会进行更改,anotherBall因为复制的值inanotherBall完全独立于存储在中的地址值someBall。如果执行此代码,我们将看到预期的结果(请注意,每个Ball对象的地址在两次执行之间会有所不同,但是第1行和第3行中的地址应该相同,而不管具体的地址值如何):

Some ball before creating another ball = Ball@6073f712
Some ball = Ball@2ff5659e
Another ball = Ball@6073f712

将值传递给方法 我们必须讨论的最后一种情况是将对象传递给方法。在这种情况下,我们可以更改与传入对象相关联的字段,但是如果我们尝试将值重新分配给参数本身,则退出方法范围时,这种重新分配将丢失。

private class Vehicle {

    private String name;

    public Vehicle(String name) {
        this.name = name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
    @Override
    public String toString() {
        return "Vehicle[name = " + name + "]";
    }
}

public class VehicleProcessor {

    public void process(Vehicle vehicle) {
        System.out.println("Entered method (vehicle = " + vehicle + ")");
        vehicle.setName("A changed name");
        System.out.println("Changed vehicle within method (vehicle = " + vehicle + ")");
        System.out.println("Leaving method (vehicle = " + vehicle + ")");
    }

    public void processWithReferenceChange(Vehicle vehicle) {
        System.out.println("Entered method (vehicle = " + vehicle + ")");
        vehicle = new Vehicle("A new name");
        System.out.println("New vehicle within method (vehicle = " + vehicle + ")");
        System.out.println("Leaving method (vehicle = " + vehicle + ")");
    }
}

VehicleProcessor processor = new VehicleProcessor();
Vehicle vehicle = new Vehicle("Some name");
System.out.println("Before calling method (vehicle = " + vehicle + ")");
processor.process(vehicle);
System.out.println("After calling method (vehicle = " + vehicle + ")");
processor.processWithReferenceChange(vehicle);
System.out.println("After calling reference-change method (vehicle = " + vehicle + ")");

如果执行此代码,则会看到以下输出:

Before calling method (vehicle = Vehicle[name = Some name])
Entered method (vehicle = Vehicle[name = Some name])
Changed vehicle within method (vehicle = Vehicle[name = A changed name])
Leaving method (vehicle = Vehicle[name = A changed name])
After calling method (vehicle = Vehicle[name = A changed name])
Entered method (vehicle = Vehicle[name = A changed name])
New vehicle within method (vehicle = Vehicle[name = A new name])
Leaving method (vehicle = Vehicle[name = A new name])
After calling reference-change method (vehicle = Vehicle[name = A changed name])

尽管有大量的输出,但是如果一次取每一行,我们会看到,当对Vehicle传递给方法的对象的字段进行更改时,将保留字段更改,但是当我们尝试重新分配时作为Vehicle参数的新对象,一旦离开方法的范围,更改将不会保留。

在前一种情况下,将Vehicle在方法外部创建的地址复制到方法的参数,因此两者都指向同一Vehicle对象。如果取消引用此指针(在访问或更改对象的字段时发生),则将更改同一对象。在后一种情况下,当我们尝试用新地址重新分配参数时,更改将丢失,因为参数仅是原始对象地址的副本,因此,一旦退出方法范围,副本将丢失。

可以从Java中的这种机制形成第二个原则:不要重新分配传递给方法的参数(由Martin Fowler在“重构对参数的删除分配”中进行了整理)。为了确保不进行方法参数的这种重新分配,可以在方法签名中将这些参数标记为final。请注意,如果需要重新分配,则可以使用新的局部变量代替参数:

public class Calculator {

    public int doSomeMath(final int input) {
         int output = input;

         if (input == 10) {
             output *= 2;
         }

         return output;
    }
}

结论

尽管诸如数据绑定方案和数据传递方案之类的基本原理在日常编程领域中似乎是抽象的,但是这些概念对于避免细微的错误至关重要。与其他编程语言(例如C ++)不同,Java将其数据绑定和传递方案简化为一条规则:数据始终按值传递。尽管此规则可能是一个严格的限制,但在完成一系列日常任务时,它的简单性以及了解如何应用此简单性可能是一项主要资产。


原文链接:http://codingdict.com