1. 程式人生 > >java中的經典問題:傳值與傳引用

java中的經典問題:傳值與傳引用

引數傳遞的祕密

知道方法引數如何傳遞嗎?
記得剛開始學程式設計那會兒,老師教導,所謂引數,有形式引數和實際引數之分,引數列表中寫的那些東西都叫形式引數,在實際呼叫的時候,它們會被實際引數所替代。
編譯程式不可能知道每次呼叫的實際引數都是什麼,於是寫編譯器的高手就出個辦法,讓實際引數按照一定順序放到一個大家都可以找得到的地方,以此作為方法呼叫的一種約定。所謂“沒有規矩,不成方圓”,有了這個規矩,大家協作起來就容易多了。這個公共資料區,現在編譯器的選擇通常是“棧”,而所謂的順序就是形式引數宣告的順序。
顯然,程式執行的過程中,作為實際引數的變數可能遍佈於記憶體的各個位置,而並不一定要老老實實的呆在棧裡。為了守“規矩”,程式只好將變數複製一份到棧中,也就是通常所說的將引數壓入棧中。

打起精神,謎底就要揭曉了。
我剛才說什麼來著?將變數複製一份到棧中,沒錯,“複製”!

這就是所謂的值傳遞。

C語言的曠世經典《The C Programming Language》開篇的第一章中,談到實際引數時說,“在C中,所有函式的實際引數都是傳‘值’的”。
馬上會有人站出來,“錯了,還有傳地址,比如以指標傳遞就是傳地址”。
不錯,傳指標就是傳地址。在把指標視為地址的時候,是否考慮過這樣一個問題,它也是一個變數。前面的討論中說過了,引數傳遞必須要把引數壓入棧中,作為地址的指標也不例外。所以,必須把這個指標也複製一份。函式中對於指標操作實際上是對於這個指標副本的操作。
Java的reference等於C的指標。所以,在Java的方法呼叫中,reference也要複製一份壓入堆疊。在方法中對reference的操作就是對這個reference副本的操作。


謎底揭曉
好,讓我們回到最初的問題上。
在changeReference中對於reference的賦值實際上是對這個reference的副本進行賦值,而對於reference的本尊沒有產生絲毫的影響。
回到呼叫點,本尊醒來,它並不知道自己睡去的這段時間內發生過什麼,所以只好當作什麼都沒發生過一般。就這樣,副本消失了,在方法中對它的修改也就煙消雲散了。

也許你會問出這樣的問題,“聽了你的解釋,我反而對changeInteger感到迷惑了,既然是對於副本的操作,為什麼changeInteger可以運作正常?”
呵呵,很有趣的大腦短路現象。
好,那我就用前面的說法解釋一下changeInteger的運作。
所謂複製,其結果必然是副本完全等同於本尊。reference複製的結果必然是兩個reference指向同一塊記憶體空間。

雖然在方法中對於副本的操作並不會影響到本尊,但對記憶體空間的修改確實實實在在的。
回到呼叫點,雖然本尊依然不知道曾經發生過的一切,但它按照原來的方式訪問記憶體的時候,取到的確是經過方法修改之後的內容。
於是方法可以把自己的影響擴充套件到方法之外。

1.所有的引數傳遞都是 傳值,從來沒有 傳引用 這個事實;
2. 所有的引數傳遞都會在 程式執行棧上 新分配一個 值 的複製品;

3.java只有按值傳遞,所謂的按地址(引用)傳遞,也屬於按值傳遞,只不過這個“值”是個地址;

4.對於引用型別的傳參也是傳值的,傳的是引用型別的值,其實就是物件的地址;

1.java引數是傳遞值的。
2.java所有對像變數都是對像的引用;

5.或者說:傳遞過去的都是拷貝,區別在於拷貝的是基本資料型別還是引用;

6.函式的形式引數,是傳入引數的拷貝;引用變數之間拷貝的是【地址】,基本變數之間拷貝的是 記憶體中的值 (被稱為直接量);
7.物件本身,與物件的地址 是2個東西,函式之間如果想【傳遞物件】,只能通過傳遞物件的地址來實現;

程式1:

public class Test
{
	String a = "123";
	public static void test(Test test)
	{
		test.a = "abc";
	}

	public static void main(String[] args)
	{
		Test test1 = new Test();
		test1.a = "567";
		System.out.println(test1.a); //567
		
		test(test1);
		System.out.println(test1.a); //abc
		
	}
}

程式2:
public class Test
{
	public static void test(String str)
	{
		str = "word";
	}

	public static void main(String[] args)
	{
		String string = "hello";
		System.out.println(string);  //hello
		test(string);
		System.out.println(string);  //hello
	}
}

程式3:
public class Test
{
	public static void test(StringBuffer str)
	{
		str.append("world");
	}
	public static void main(String[] args)
	{	
		StringBuffer str = new StringBuffer("hello");
		System.out.println(str);  //hello
		test(str);
		System.out.println(str);  //helloworld
	}
}

程式4:

public class Test
{
	public static void test(StringBuffer str)
	{
		str = new StringBuffer("world");
	}
	public static void main(String[] args)
	{	
		StringBuffer str = new StringBuffer("hello");
		System.out.println(str);  //hello
		test(str);
		System.out.println(str);  //hello
	}
}


1.傳遞的引數“值” 會在 函式當前執行棧上 複製一份。所有的 操作和賦值 都是對那個 引數“值”副本 進行的。操作能夠 改變成員變數,因為引數“值”副本 是一個 地址,能夠正確指向成員變數的位置。而賦值 則根本沒用,因為只是改變了當前函式棧內 引數“值”副本 的內容,而沒有改變上一層函式棧內的 對應“值”;

2.=和.

=(等號)操作的是變數的值;

.(句點)操作的是引用的值;

3.關於傳參的問題只要把棧和堆搞明白了就應該很容易理解了:

java傳參都是傳值,也就是把棧裡面的值複製一份傳給引數;
當然如果是原始型別就是棧內原始型別值複製了一份傳遞;
如果是物件或者陣列這樣的引用型別,那麼棧裡面的值就是該物件的引用的值具體說就是地址了,把這個值複製一份傳給方法,也就是複製了一個引用給方法去使用;

例如:
 public void func1(int a){}
 
 int i = 10;
 func1(i);  的時候,實際上是作了a=i的操作
 而物件
 public void fun2(Object o){}
 
Object m = new Object();
 func2(m);的時候,也其實是作了o=m的操作

另一個程式:
import java.io.Console;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintWriter;
import java.util.*; 

class People
{   
	public int age;  
	public static void Swap(People a1, People a2)
	{     
		People tem;   
		tem = a1;    
		a1 = a2;     
		a2 = tem; 
	}  
	public static void Change(People a1, People a2)
	{      
		a1.age = 250;  
		a2.age = 250;   
	}
}
public class Empty
{ 
	public static void main(String[]args)    
	{        
		People a1 = new People();    
		People a2 = new People();  
		a1.age = 1;  
		a2.age = 2;  
		
		//這裡沒能交換,看起來是按值傳遞       
		People.Swap(a1, a2);      
		System.out.printf("%d \t %d", a1.age, a2.age);             
		System.out.println();  
		
		//這裡修改了變數,看起來是按引用傳遞        People.Change(a1, a2);    
		System.out.printf("%d \t %d", a1.age, a2.age); 
	}
}



看上圖,方法的引數可以看做是另一個變數:a1_和a2_
它們只是和傳入引數擁有相同的物件地址而已;
對於交換物件也只是對a1_和a2_這兩個做交換罷了,不會影響a1,a2;
但如果是通過記憶體地址去修改物件內部,如改變age的值,則會影響a1,a2;


首先,推薦對Java有一定理解的同仁一本書《Practical Java》。在《Practical Java》中也有一個章節介紹Java中關於傳值和傳引用的問題,堪稱經典。《Practical Java》在Java中,事實上底層工作原理不存在傳引用的概念,這也象《Practical Java》中所說的那樣,Java中只有傳值。這句話理解起來需要費一定的周折。

熟悉C的程式設計師都用過指標,對指標可謂愛之深恨之切。指標是指向一塊記憶體地址的記憶體資料(有些拗口),也就是說指標本身是一個佔用4位元組記憶體的int(32 位系統內),而這個int值恰恰又是另一塊記憶體的地址。比如"hello"這個字串,存放在@0x0000F000這個地址到@0x0000F005這段記憶體區域內(包括0x00的結束位元組)。而在@0x0000FFF0到@0x0000FFF03這四個位元組記憶體放著一個int,這個int的值是 @0x0000F000。這樣就形成了一個指向"hello"字串的指標。

在Java中,很多人說沒有指標,事實上,在Java更深層次裡,到處都是大師封裝好的精美絕倫的指標。為了更容易的講解Java中關於類和型別的呼叫,Java中出現了值與引用的說法。淺顯的來說,我們可以認為Java中的引用與C中的指標等效(其實差別非常非常大,但是為了說明我們今天的問題,把他們理解為等效是沒有任何問題的)。

所謂傳引用的說法是為了更好的講解呼叫方式。基於上面對指標的理解,我們不難看出,指標其實也是一個int值,所謂傳引用,我們是複製了複製了指標的int值進行傳遞。為了便於理解,我們可以姑且把指標看作一種資料型別,透明化指標的int特性,從而提出傳引用的概念。

重申一遍:Java中只有傳值。

1所謂傳值和傳引用

傳值和傳引用的問題一直是Java裡爭論的話題。與C++不同的,Java裡面沒有指標的概念,Java的設計者巧妙的對指標的操作進行了管理。事實上,在懂C++的Java程式設計師眼中,Java到處都是精美絕倫的指標。
下面舉個簡單的例子,說明什麼是傳值,什麼是傳引用。
//例1
void method1(){
int x=0;
this.change(x);
System.out.println(x);
}

void change(int i){
i=1;
}

很顯然的,在mothod1中執行了change(x)後,x的值並不會因為change方法中將輸入引數賦值為1而變成1,也就是說在執行change(x)後,x的值z依然是0。這是因為x傳遞給change(int i)的是值。這就是最簡單的傳值。
同樣的,進行一點簡單的變化。
//例2
void method1(){
StringBuffer x=new StringBuffer("Hello");
this.change(x);
System.out.println(x);
}

void change(StringBuffer i){
i.append(" world!");
}
看起來沒什麼變化,但是這次mothed1中執行了change (x)後,x的值不再是"Hello"了,而是變成了"Hello world!"。這是因為x傳遞給change(i)的是x的引用。這是最經典的傳引用。
似乎有些奇怪了,兩段程式沒有特別的不同,可是為什麼一個傳的是值而另一個傳的是引用呢?......

2非要搞清楚傳值還是傳引用的問題嗎?

搞清楚這自然是有必要的,不然我也不需要寫這麼多了,不過的確沒有到"非要"的地步。


首先,如果我們不太關心什麼是傳值什麼是傳引用,我們一樣能寫出漂亮的程式碼,但是這些程式碼在執行過程中可能會存在著極大的隱患。全域性變數是讓大家深惡痛絕(又難以割捨)的東西,原因就是使用全域性變數要特別注意資料的保護。如果在多執行緒的程式裡使用全域性變數簡直就等於跟自己過不去。不瞭解傳值和傳引用的問題,跟使用全域性變數不考慮資料保護的罪過是不相上下下的,甚至有時候比它還要糟。你會莫名其妙,為什麼我的返回引數沒有起作用,為什麼我傳進去的引數變成了這樣......?
一個例子:
//例3
void mothed1(){
int x=0;
int y=1;
switchValue(x,y);
System.out.println("x="+x);
System.out.println("y="+y);
}
void switchValue(int a,int b){

int c=a;
a=b;
b=c;
}
上面是一個交換a,b值的函式,看起來似乎蠻正確的,但是這個函式永遠也不會完成你想要的工作。
還有一個例子:
//例4
StringBuffer a=new StringBuffer("I am a ");
StringBuffer b=a;
a.append("after append");
a=b;
System.out.println("a="+a);
在程式設計過程中,經常會遇到這種情況,一個變數的值要被臨時改變一下,等用完之後再恢復到開始的值。就好像上面的例子,a為了保持它的值,使用b=a做賦值,之後a被改變,再之後a把暫存在b裡面的值取回來。這是我們一廂情願的想法,而事實上,這段程式碼執行後,你會發現a的值已經改變了。
以上是兩個最簡單的例子,真正的程式開發過程中,比這要複雜的情況每天都會遇到。

3型別和類

Java 提出的思想,在Java裡面任何東西都是類。但是Java裡面同時還有簡單資料型別int,byte,char,boolean,與這些資料型別相對應的類是Integer,Byte,Character,Boolean,這樣做依然不會破壞Java關於任何東西都是類的提法。這裡提到資料型別和類似乎和我們要說的傳值和傳引用的問題無關,但這是我們分辨傳值和傳引用的基礎。

4試圖分辨傳值還是傳引用

為什麼是"試圖分辨"呢?很簡單,傳值和傳引用的問題無處不在,但是似乎還沒有人能正統的給出標準,怎樣的就是值拷貝呼叫,怎樣的就是引用呼叫。面對這個問題,我們更多的應該是來自平時積累對Java的理解。
回過頭來,我們分析一下上面的幾個例子:
先看例1,即使你不明白為什麼,但是你應該知道這樣做肯定不會改變x的值。為了方便說明,我們給例子都加上行號。
//例1
1 void method1(){
2 int x=0;
3 this.change(x);
4 }
5
6 void change(int i){
7 i=7;
8}
讓我們從記憶體的儲存方式看一下x和I之間到底是什麼關係。
在執行到第2行的時候,變數x指向一個存放著int 0的記憶體地址。

變數x---->[存放值0]

執行第3行呼叫change(x)方法的時候,記憶體中是這樣的情形:x把自己值在記憶體中複製一份,然後變數i指向這個被複製出來的0。

變數x---->[存放值0]
↓進行了一次值複製
變數x---->[存放值0]

這時候再執行到第7行的時候,變數i的被賦值為7,而這一步的操作已經跟x沒有任何關係了。

變數x---->[存放值0]

變數x---->[存放值7]

說到這裡應該已經理解為什麼change(x)不能改變x的值了吧?因為這個例子是傳值的。
那麼,試著分析一下為什麼例三中的switchValue()方法不能完成變數值交換的工作?
再看例2。
//例2
1void method1(){
2 StringBuffer x=new StringBuffer("Hello");
3 this.change(x);
4}
5
6void change(StringBuffer i){
7 i.append(" world!");
8}
例2似乎和例1從程式碼上看不出什麼差別,但是執行結果卻是change(x)能改變x的值。依然才從記憶體的儲存角度來看看例2的蹊蹺在哪裡。
在執行到第2行時候,同例1一樣,x指向一個存放"Hello"的記憶體空間。

變數x---->[存放值"Hello"]

接下來執行第三行change(x),注意,這裡就與例1有了本質的不同:呼叫change(x)時,變數i也指向了x指向的記憶體空間,而不是指向x的一個拷貝。

變數x \
-->[存放值"Hello"]
變數x /

於是,第7行對i呼叫append方法,改變i指向的記憶體空間的值,x的值也就隨之改變了。

變數x \
-->[追加為"Hello World!"]
變數x /

為什麼x值能改變呢?因為這個例子是傳引用的。
這幾個例子是明白了,可是很多人會開始有另一個疑問了:這樣看來,到底什麼時候是傳的值什麼時候是傳得引用呢?於是,我們前面講到的型別和類在這裡就派上了用場:對於引數傳遞,如果是簡單資料型別,那麼它傳遞的是值拷貝,對於類的例項它傳遞的是類的引用。需要注意的是,這條規則只適用於引數傳遞。為什麼這麼說呢?我們看看這樣一個例子:
//例5
String str="abcdefghijk";
str.replaceAll("b","B");
這兩句執行後,str的內容依然是"abcdefghijk",但是我們明明是對str操作的,為什麼是這樣的呢?因為str的值究竟會不會被改變完全取決於replaceAll這個方法是怎麼實現的。類似的,有這樣一個例子:
//例6
1 void method1() {
2 StringBuffer x = new StringBuffer("Hello");
3 change1(x);
4 System.out.println(x);
5 }
6
7 void method2() {
8 StringBuffer x = new StringBuffer("Hello");
9 change2(x);
10 System.out.println(x);
11 }
12
13 void change1(StringBuffer sb) {
14 sb.append(" world!");
15 }
16
17 void change2(StringBuffer sb) {
18 sb = new StringBuffer("hi");
19 sb.append(" world!");
20 }
呼叫method1(),螢幕列印結果為:"Hello world!"
呼叫method2(),我們認為結果應該是"hi world",因為sb傳進來的是引用。可是實際執行的結果是"Hello"!
難道change2()又變成傳值了?!其實change1()和change2()的確都是通過引數傳入引用,但是在方法內部因為處理方法的不同而使結果大相徑庭。我們還是從記憶體的角度分析:
執行method1()和change1()不用再多說了,上面的例子已經講解過,這裡我們分析一下method2()和change2()。
程式執行到第8行,x指向一個存放著"Hello"的記憶體空間。

變數x---->[存放值"Hello"]

第9行呼叫change2,將sb指向x指向的記憶體空間,也就是傳入x的引用。

變數x \
-->[存放值"Hello"]
變數x /

到這裡為止還沒有什麼異樣,接下來執行18行,這裡就出現了類似傳入值拷貝的變化:new 方法並沒有改變sb指向記憶體的內容,而是在內從中開闢了一塊新的空間存放串"hi",同時sb指向了這塊空間。

變數x---->[存放值"Hello"]
×原有的引用被切斷
變數x---->[另一塊存放"hi"的空間]

接下來再對sb進行append已經和x沒有任何關係了。
所以,還有一條不成規則的規則:對於函式呼叫,最終效果是什麼完全看函式內部的實現。比較標準的做法是如果會改變引用的內容,則使用void作為方法返回值,而不會改變引用內容的則在返回值中返回新的值。
雖然已經說了這麼多,但是感覺傳值還是傳引用的問題依然沒有完全說清楚。因為這個問題本身就是很難歸納總結的問題,所以更多的理解要靠平時的積累和形成。下面幾個例子,給大家嘗試進行分析。
//例7,列印結果是什麼?
public static void main(String[] args) {
int a;
int b;
StringBuffer c;
StringBuffer d;
a = 0;
b = a;
c = new StringBuffer("This is c");
d = c;

a = 2;
c.append("!!");

System.out.println("a=" + a);
System.out.println("b=" + b);
System.out.println("c=" + c);
System.out.println("d=" + d);
}

//例8,列印結果是什麼?
public class Test{

public static void main(String[] args) {

StringBuffer sb = new StringBuffer("Hello ");

System.out.println("Before change, sb = " + sb);

changeData(sb);

System.out.println("After changeData(n), sb = " + sb);

}

public static void changeData(StringBuffer strBuf) {

StringBuffer sb2 = new StringBuffer("Hi ");

strBuf = sb2;

sb2.append("World!");

}

}


文章部分參考自: