1. 程式人生 > >關於java的引數傳遞(值傳遞、引用傳遞和傳值、傳引用等)

關於java的引數傳遞(值傳遞、引用傳遞和傳值、傳引用等)


所謂引數傳遞就是用函式呼叫所給出的實參(實際引數)向函式定義所給出的形參(形式引數)設定初始值的過程。基本的有三種引數分別為: (1)傳值:   (2)傳址(即是傳指標) (3)傳引用 以上這些都是根據引數的型別來分別的,是指傳遞的東西是什麼,而不是指傳遞過程,但是在傳遞過程中也有和它們比較混淆的名詞,這就是是值傳遞和引用傳遞,總體上函式呼叫可以分為兩類,是根據傳遞時的過程來區分為:值傳遞與引用傳遞。這個值傳遞和引用傳遞實際與傳遞的東西無關。
一般對於值傳遞和引用傳遞會有這麼幾種錯誤理解:
第一種是:Java是引用傳遞,會理解為Java的形參是物件的引用所以才叫引用傳遞。問題在於引用傳遞這個詞不是這個意思而是形容呼叫方式而不是引數本質的型別的。所以即使有人因為明白引用本身也是個值,然後覺得Java其實是值傳遞了,雖然答案是對的其實這種理解也是錯的。這種理解叫“傳遞的是值”而非“值傳遞”,在下面會解釋這個問題。然後第二種是:值型別是值傳遞,引用型別用的是引用傳遞。第三種是:認為所有的都是值傳遞,因為引用本質上也是個值,本質就是個指標嘛。第四種是:常出現在C++程式設計師中,宣告的引數是引用型別的,就是引用傳遞;宣告的引數是一般型別或指標的就是值傳遞。也有人把指標歸為引用傳遞,因為它比較特殊,所以歸為哪邊都不合適。
值傳遞與引用傳遞,在計算機領域是專有名詞。值傳遞和引用傳遞,屬於函式呼叫時引數的求值策略,按值呼叫表示方法接收的是呼叫者提供的值,按引用呼叫表示方法接收的是呼叫者提供的變數地址,一個方法可以修改傳遞引用所對應的變數值,而不能修改傳遞值呼叫所對應的變數值,按...呼叫(call by)是用來描述各種語言中方法引數的傳遞方式,這是對呼叫函式時,求值和傳值的方式的描述,而非傳遞的內容的型別(內容指:是值型別還是引用型別,是值還是指標)。值型別/引用型別,是用於區分兩種記憶體分配方式,值型別在呼叫棧上分配,引用型別在堆上分配。一個描述記憶體分配方式,一個描述引數求值策略,兩者之間無任何依賴或約束關係。 所以: 值傳遞:方法呼叫時,實際引數把它的值傳遞給對應的形式引數,函式接收的是原始值的一個copy,此時記憶體中存在兩個相等的基本型別,即實際引數和形式引數,後面方法中的操作都是對形參這個值的修改,不影響實際引數的值。被調函式的形式引數作為被調函式的區域性變數處理,即在堆疊中開闢了記憶體空間以存放由主調函式放進來的實參的值,從而成為了實參的一個副本。值傳遞的特點是被調函式對形式引數的任何操作都是作為區域性變數進行,不會影響主調函式的實參變數的值。
引用傳遞:方法呼叫時,實際引數的引用被傳遞給方法中相對應的形式引數,函式接收的是被引用的記憶體地址,在方法執行中形參和實參作用等同,指向同一塊記憶體地址,方法執行中對引用的操作將會影響到實際物件。被調函式的形式引數雖然也作為區域性變數在堆疊中開闢了引用空間,但是這時已經相當於是主調函式放進來的實參變數的別名。被調函式對形參的任何操作都被處理成間接定址,即通過堆疊中存放的地址訪問主調函式中的實參變數。所以被調函式對形參做的任何操作都影響了主調函式中的實參變數。就是說此時如果對目標物件進行修改記憶體中的資料也會改變。
在java中方法引數傳遞方式是按值傳遞。如果引數是基本型別,傳遞的是基本型別的字面量值的拷貝。如果引數是引用型別,傳遞的是該參量所引用的物件在堆中地址值的拷貝。
傳值的方式傳引用。 或者說傳值的方式傳地址。
在函式呼叫過程中,呼叫方提供實參,這些實參可以是常量:Call(1);也可以是變數:Call(x);也可以是他們的組合:Call(2 * x + 1);也可以是對其它函式的呼叫:Call(GetNumber());但是所有這些實參的形式,都統稱為表示式。求值即是指對這些表示式的簡化並求解其值的過程。
求值策略(值傳遞和引用傳遞)的關注的點在於,這些表示式在呼叫函式的過程中,求值的時機、值的形式的選取等問題。求值的時機,可以是在函式呼叫前,也可以是在函式呼叫後,由被呼叫者自己求值。
而且,除了值傳遞和引用傳遞,還有一些其它的求值策略。這些求值策略的劃分依據是:求值的時機(呼叫前還是呼叫中)和值本身的傳遞方式。
求值策略 求值時間 傳值方式
值傳遞 呼叫前 值的結果(是原值的副本)
引用傳遞 呼叫前 原值(原始物件)
名傳遞 呼叫後 與值無關的一個名

下表列出了一些二者在行為表象上的區別。
  值傳遞 引用傳遞
根本區別 會建立副本 不建立副本
所以 函式中無法改變原始物件 函式中可以改變原始物件

這裡的改變是指把一個變數指向另一個物件,而不是指僅僅改變屬性或是成員什麼的,比如Java,所以說Java是Pass by value,原因是它呼叫時進行Copy,實參不能指向另一個物件,而不是因為被傳遞的東西本質上是個Value,這麼講計算機上什麼不是Value。
所以這些行為就像在上面提到的與引數型別是值型別還是引用型別無關。對於值傳遞,無論是值型別還是引用型別,都會在呼叫棧上建立一個副本,不同的是對於值型別而言,這個副本就是整個原始值的複製。而對於引用型別而言,由於引用型別的例項在堆中,在棧上只有它的一個引用(一般情況下是指標),其副本也只是這個引用的複製,而不是整個原始物件的複製。這便引出了值型別和引用型別的最大區別:值型別用做引數會被複制,但是很多人誤以為這個區別是值型別的特性,其實這是值傳遞帶來的效果,和值型別本身沒有關係只是最終結果是這樣。
求值策略定義的是函式呼叫時的行為,並不對具體實現方式做要求,但是指標由於其彙編級支援的特性,成為實現引用傳遞方式的首選。但是純理論上,你完全可以不用指標,比如用一個全域性的引數名到物件地址的HashTable來實現引用傳遞,只是這樣效率太低,所以根本沒有哪個程式語言會這樣做。所以對於Java的函式呼叫方式最準確的描述是:引數藉由值傳遞方式,傳遞的值是個引用。在字面上與Java總是傳值的事實衝突,於是對於Java,Python、Ruby、JavaScript等語言使用的這種求值策略,起了一個更貼切名字,叫Call by sharing即共享傳參。
在上面對於傳遞的引數種類和引數的傳遞方式進行了分析,現在看一下在傳遞引數種類的不同前提下,傳遞引數的過程會有什麼變化: 傳值:是把實參的值賦值給行參那麼對行參的修改,不會影響實參的值函式引數壓棧的是引數的副本,任何的修改是在副本上作用,沒有作用在原來的變數上。 
傳地址:是傳值的一種特殊方式,只是他傳遞的是引用地址,不是普通的基本型別,那麼傳地址以後實參和行參都指向同一個物件,但是壓棧的是指標變數的副本,當你對指標解指標操作時其值是指向原來的那個變數所以對原來變數操作。
傳引用:真正的以引用別名的方式傳遞引數,傳遞以後行參和實參都是同一個物件只是他們名字不同而已,對行參的修改將影響實參的值,壓棧的是引用別名的副本。由於引用是指向某個變數的,對引用的操作其實就是對他指向的變數的操作。(作用和傳指標一樣,只是引用少了解指標的草紙)  

前面討論了各種求值策略的內涵。下面以C++的程式碼為例看一個例子來區分開函式呼叫的行為和函式傳遞的值的區別:
#include <iostream> using namespace std; void ByValue(int a) { a = a + 1; }
void ByRef(int& a) { a = a + 1; }
void ByPointer1(int* a) { int b=0; a = &b; }
void ByPointer2(int* a) { int b=0; *a = b; } int main(int argc, const char * argv[]) { int v = 1; ByValue(v); cout<<v<<endl; ByRef(v); cout<<v<<endl; // Pass by Value int *vp = &v; ByPointer1(vp); cout<<*vp<<endl; // Pass by Reference ByPointer2(&v); cout<<v<<endl; return 0; } Main函式裡的前兩種方式沒有什麼好說,第一個是值傳遞,第二個函式是引用傳遞,但是後面兩種,同一個函式,一次呼叫是 Call by reference, 一次是 Call by value。因為:ByPointer(vp)沒有改變vp其實是做了一次複製,所以改變的只是複製沒有改變vp的指向,而ByPointer(&v)改變了v。你可能認為這傳遞的其實是v的地址,而ByPointer無法改變v的地址,所以這是Call by value。但是v的地址是個純資料在呼叫的方程式碼中並不存在,對於呼叫者而言只有v的確被ByPointer函式改了,這個結果,正是Call by reference的行為。從行為考慮,才是求值策略的本意。如果把所有東西都抽象成值,從資料考慮問題,那根本就沒有必要引入求值策略的概念去混淆視聽
C語言不支援引用,只支援指標,但是如上文所見,使用指標的函式,不能通過簽名明確其求值策略。所以C++引入了引用,它的求值策略可以確定是Pass by reference。於是C++的語言本身支援Call by value和Call by reference兩種求值策略,但是卻提供了三種語法去做這倆事兒。而C#的設計就相對合理,函式聲明裡,有ref/out,就是引用傳遞,沒有ref/out,就是值傳遞,與引數型別無關。
而對於之上的程式就和其他的程式一樣執行永遠都是在棧中進行的,因而引數傳遞時,只存在傳遞基本型別和物件引用的問題。不會直接傳物件本身。所以Java在方法呼叫傳遞引數時,因為沒有指標,所以它都是進行傳值呼叫(這點可以參考C的傳值呼叫)。因此,很多書裡面都說Java是進行傳值呼叫,這點沒有問題,而且也簡化的C中複雜性。但是傳引用的錯覺是如何造成的呢?在執行棧中,基本型別和引用的處理是一樣的都是傳值,所以如果是傳引用的方法呼叫,也同時可以理解為“傳引用值”的傳值呼叫,即引用的處理跟基本型別是完全一樣的。但是當進入被呼叫方法時,被傳遞的這個引用的值,被程式解釋(或者查詢)到堆中的物件,這個時候才對應到真正的物件。如果此時進行修改,修改的是引用對應的物件,而不是引用本身,即:修改的是堆中的資料。所以這個修改是可以保持的了。
  Java中程式設計師無法直接操作指標,對於傳值還是傳地址很模糊,但我們知道java中變數分為兩類,一類是基本變數,一類是引用。其實當我們把實參a,b傳進函式後,就相當於把a,b的值分別傳給了函式用於接收a,b的區域性變數,那麼不管是傳進去引用還是值,實參a,b的值都不會被改變,因為操作的時函式中接收a,b傳遞的值的區域性變數,函式執行完畢,這兩個區域性變數也就不存在了。那為什麼傳進去引用可以修改a,b的值呢,這是不違反我上面說的原理的,傳引用相當於把a,b的記憶體地址傳遞進函式,函式的區域性變數接收了a,b的地址,a,b的引用地址是無法在函式內部被改變的,但是a,b指向的值是可以改變的。      Java中無論傳值還是傳引用都是傳的引數的副本,副本只在函式內部有效。而當傳引用時,傳進去的是自己副本的地址,地址無法被改變但是地址指向的值可以被改變。所以說java中都可以看做是傳值,普通型別引數傳遞的是引數的值的副本,在函式內部值修改副本,無法對原始資料產生影響,當引用作為引數時,傳遞的是引用型別的地址,函式內部接收引用的地址的副本,對引用地址所指向的值可以進行修改,但引用的地址是無法修改的,相當於引用地址的值無法修改。String和StringBuffer作為引數為什麼不同,相信瞭解String特性的都知道,String生成例項之後他的值無法修改,如果對它進行修改會產生新的物件,所以String的地址傳入函式內部,函式內部對它指向的值進行操作,最終卻生成了另外一個物件,而上面所說無法對原String的地址修改,所以對於新生成的String物件無法影響原來的String物件,並且它的生命週期只在函式內部。
在說說當引數型別不同而且引數傳遞過程也不同的互相混雜情況下的效率問題: 從傳遞效率上:這裡所說傳遞效率是呼叫被調函式的程式碼將實參傳遞到被調函式體內的過程。這個效率不能一概而論。對於內建的int  char   short long float等4位元組或以下的資料型別而言,實際上傳遞時也只需要傳遞1-4個位元組,而使用指標傳遞時在32位cpu中傳遞的是32位的指標,4個位元組,都是一條指令,這種情況下值傳遞和指標傳遞的效率是一樣的,而傳遞double  long long等8位元組的資料時,在32位cpu中,其傳值效率比傳遞指標要慢,因為8個位元組需要2次取完。而在64位的cpu上,傳值和傳址的效率是一樣的。再說引用傳遞,這個要看編譯器具體實現,引用傳遞最顯然的實現方式是使用指標,這種情況下與指標的效率是一樣的,而有些情況下編譯器是可以優化的,採用直接定址的方式,這種情況下,效率比傳值呼叫和傳址呼叫都要快,與上面說的採用全域性變數方式傳遞的效率相當。再說自定義的資料型別,class  struct定義的資料型別。這些資料型別在進行傳值呼叫時生成臨時物件會執行建構函式,而且當臨時物件銷燬時會執行解構函式,如果建構函式和解構函式執行的任務比較多,或者傳遞的物件尺寸比較大,那麼傳值呼叫的消耗就比較大。這種情況下,採用傳址呼叫和採用傳引用呼叫的效率大多數下相當,正如上面所說,某些情況下引用傳遞可能被優化,總體效率稍高於傳址呼叫。 從執行效率上:這裡所說的執行效率,是指在被呼叫的函式體內執行時的效率 因為傳值呼叫時,當值被傳到函式體內,臨時物件生成以後,所有的執行任務都是通過直接定址的方式執行的,而指標和大多數情況下的引用則是以間接定址的方式執行的,所以實際的執行效率會比傳值呼叫要低。如果函式體內對引數傳過來的變數進行操作比較頻繁,執行總次數又多的情況下,傳址呼叫和大多數情況下的引用引數傳遞會造成比較明顯的執行效率損失。所以具體的執行效率要結合實際情況,通過比較傳遞過程的資源消耗和執行函式體消耗之和來選擇哪種情況比較合適。而就引用傳遞和指標傳遞的效率上比,引用傳遞的效率始終不低於指標傳遞,所以從這種意義上講,在c++中進行引數傳遞時優先使用引用傳遞而不是指標。
結合上面的分析,關於值傳遞和引用傳遞可以得出這樣的結論: (1)基本資料型別傳值,對形參的修改不會影響實參; (2)引用型別傳引用,形參和實參指向同一個記憶體地址(同一個物件),所以對引數的修改會影響到實際的物件; (3)String, Integer, Double等immutable的型別特殊處理,可以理解為傳值,最後的操作不會修改實參物件。

傳值:是把實參的值賦值給行參那麼對行參的修改,不會影響實參的值函式引數壓棧的是引數的副本,任何的修改是在副本上作用,沒有作用在原來的變數上。 
傳地址:是傳值的一種特殊方式,只是他傳遞的是引用地址,不是普通的基本型別,那麼傳地址以後實參和行參都指向同一個物件,但是壓棧的是指標變數的副本,當你對指標解指標操作時其值是指向原來的那個變數所以對原來變數操作。
傳引用:真正的以引用別名的方式傳遞引數,傳遞以後行參和實參都是同一個物件只是他們名字不同而已,對行參的修改將影響實參的值,壓棧的是引用別名的副本。由於引用是指向某個變數的,對引用的操作其實就是對他指向的變數的操作。(作用和傳指標一樣,只是引用少了解指標的草紙)  
在最後說幾個在引數傳遞過程中一些共同的有用的技術: 1. const關鍵字:當你的引數是作為輸入引數時,你總不希望你的輸入引數被修改,否則有可能產生邏輯錯誤,這時可以在宣告函式時在引數前加上const關鍵字,防止在實現時意外修改函式輸入,對於使用你的程式碼的程式設計師也可以告訴他們這個引數是輸入,而不加const關鍵字的引數也可能是輸出。   2. 預設值:給引數新增一個預設值是一個很方便的特性,這樣你就可以定義一個具有好幾個引數的函式,然後給那些不常用的引數一些預設值,客戶程式碼如果認為那些預設值正是他們想要的,呼叫函式時只需要填一些必要的實參就行了,這樣就省去了過載好幾個函式的麻煩。
3.引數順序。當同個函式名有不同引數時,如果有相同的引數儘量要把引數放在同一位置上,以方便客戶端程式碼。