天天看點

由指派中的“别名現象”引發的思考

    最近在重讀《Java程式設計思想》(第四版),進行到 第三章 操作符,在3.4節指派部分,一個本不應該糾結的“别名現象”引發了關于形參與實參,值傳遞與引用傳遞幾個基礎問題的迷惑,經吸取前輩經驗與親自實踐,将本部分内容整理如下,希望能對在此間問題上有所困惑的朋友有所幫助:

    首先,關于指派“=”的概念:即 将右值複制給左值,分為基本資料類型指派和引用類型“指派”。基本資料類型的指派很簡單且沒有疑點,隻是把一個地方的内容簡單複制到另一個地方,指派操作結束後,無論操作哪一方,對另一方都不會造成任何影響,二者再無瓜葛;而對引用類型(包括對象及字元串,字元串較特殊,将在下文補充說明)的“指派”,本質上是将對象的引用進行複制,書中例:有c,d兩個對象,現執行 c=d,則c和d都指向了原本隻有d指向的對象。之後書裡舉出了一個描述這一過程的完整代碼執行個體,在此不再贅述了,隻拿出關鍵的幾行代碼簡單描述以引出“别名現象”。沿用上面的c、d兩個對象,他們是同一個類的兩個執行個體,都有非私有的成員變量name(書中此處還未涉及到封裝),有:

例:
c.name = "c";
d.name = "d";
c = d;
c.name = "e";
System.out.println(d.name);  (書中此處使用前述的靜态導入,簡化了輸出語句的寫法,本例沒必要)
/* Output: e */
           

    慚愧的講,初看這種 對象.屬性 的代碼以及輸出結果,我的大腦是當機了1秒鐘的,主要是 對象.屬性 的用法确實遙遠了,後來想到書中此處還沒有涉及到封裝,這種最基礎最簡單的用法倒是讓我想到初學的情景,恍如隔世。簡單解釋一下我的例子(其實沒必要):c,d的name屬性都有各自的初值,将d賦給c,改變c的name屬性,d的name屬性也随之改變,這是由于指派操作将d的引用複制給了c,使得c和d指向了同一個對象,通過其中一個(例中是c)去改變這個對象,同樣指向這個對象的另一個(d)自然随之改變;就像你叫李四(本來我随手打的張三),小名小四,李四找了個女朋友,這個女朋友同時也是小四的女朋友一樣。這就是書中描述的“别名現象”,或者說是“别名陷阱”我覺得更為合适。

    如何規避這一陷阱?書中的方式是 c.name = d.name ; 這樣c還是那個c(c所指向的那個對象),d還是那個d,二者獨立,隻不過c“改名”了(name屬性的值變了)。書中接着提到這種直接操作對象内的域(例中對象的成員變量)容易導緻混亂,違背面向對象的原則(封裝,這點相信你在看這個例子之初就應該察覺到了,如果是沒概念的新手,相信你在不久之後的get/set海洋中撲騰的時候也一定會感同身受的)。這部分還是好了解的,之後是方法調用中的别名問題,這就稍稍糾結了。

    依然用前述的c,d兩個對象,省略程式的其他環節,隻保留方法聲明和方法調用,有:

例:
void change(Letter d){
    d.name = "d";
}

c.name = "c";
change(c);
System.out.println(c.name);

/* Output: d */
           

    最開始看到這樣的結果我是迷惑的,因為有一句話深深地印在我的腦海裡,就是:形參的改變不會影響實參。看到這,請你把這句話忘掉。這個觀點的完整表述應該是這樣的:對于基本類型資料,字元串(特殊,最後說明),和引用本身(我指的是位址值,可能這樣了解不太準确,但是我想表達的意思是 位址值)這三種形參,無論在方法内怎樣改變,如果沒有将更改後的值傳回,并将傳回值指派給原實參,那麼都不會影響原實參。基本類型無需多說;字元串隻是機制特殊,表現形式與基本類型相同,稍後補充;對于引用本身我需要補充一個例子來說明,很簡單:

例:
void change(Letter d){
    d = new Letter();
}
           

    如果你在方法調用前,方法内部,和方法調用後列印三次對象的位址值(我不寫,寫了你記不住,自己試印象深),你會清晰地發現,方法内形參引用(位址值)确實改變了,但是出了方法體,實參還是那個實參,引用(位址值)沒變。但是不知你是否注意了我加粗顯示的兩句話,如果将變更後的形參傳回并指派給實參,那實參就會發生改變。如下:

例:
Letter change(Letter d){
    d = new Letter();
    return d;
}

c = change(c);
           

此時再列印三次位址值,你會發現原先的實參被改變後的形參“同化了”。

    之是以強調引用本身(位址值),是因為雖然引用本身在方法内的更改不會影響到方法外【1】(我指void),但是如果該引用指向的内容在方法内更改了,對不起,方法外實參也會做出相應改變。這是因為形參可以視為與實參有着相同的引用【2】。與例1中闡述的道理相同,這也就有了例2的結果。或者我使用如下寫法,你可能恍然大悟(我反正是恍然大悟):

例:
void change(Letter d){
    d.setName("d");
}
           

    至此,我用了幾個很簡單很簡單真的不能再簡單的幾個例子,和啰啰嗦嗦的解釋,差不多是将“别名現象”解釋清楚了(額,我覺得是……),下面補充說明字元串及值傳遞引用傳遞的問題。

    字元串(常量)對象是一種特殊的引用類型,用final修飾,存放在字元串常量池中。每一次指派,先會在常量池中查找是否有相同對象,有則将這一對象的引用複制給左值,以完成指派,沒有則建立新對象,将新對象引用複制給左值。隻不過他的表現出的現象與基本類型資料一緻。如果你對前述引用指向的内容 這部分體會的不透徹,你可以自己嘗試在方法内以字面量的形式給字元串賦新值(本質上是在方法内改變引用本身(指位址值),廢棄原先的指向,轉而指向新的字元串常量對象所在的空間)對比 使用StringBuilder對象改變内容(如append())對方法外實參産生的影響。

    至于值傳遞和引用傳遞的問題,哎….這個就比較麻煩。我看了很多博文,很多知友的回答(為此找回了失散多年的知乎密碼,這些回答中,Hugo Gu的回答給我觸動最大),以及stackoverflow中靠前的一部分回答,現整理出來,結合自己的了解來說一說。很久以前,我對這兩個概念的了解是基本類型,采用值傳遞;引用類型,采用引用傳遞。如果你一聽,覺得:哎,沒毛病!哈哈,那老鐵咱先握個手。後來,看有的文章說,java隻有值傳遞,因為引用傳遞傳遞的值是位址值,這不也是值麼?天哪!簡直不能更有道理!是以很長一段時間,我對java隻有值傳遞的了解隻停留在這個層面上,直到寫這篇文章之前。學習嘛,總是在曲折中前進的,可能我現在寫下的“正确答案”,在将來的某天就會覺得哎呀漏洞百出,以下的全部讨論是建立在java隻有值傳遞,以及下述的值傳遞、引用傳遞概念的基礎之上的,如果二者任一有偏差,那結論一定不準确甚至錯誤。值傳遞和引用傳遞的概念我看了許多,目前傾向于這種表述(希望你能了解半路出家的程式員):所謂值傳遞,是指在方法調用時,将實參拷貝一份,将副本作為形參傳遞到方法内部,這樣,方法内形參的改變不會影響到方法外實參(希望你完整的看完前文,确切的知道這裡的“不會影響”是指什麼)。首先,要注意區分值傳遞和傳遞的内容(我不想用“傳遞的值”這種容易引入困惑的表述),并不是說傳遞的内容是基本類型資料就是值傳遞,傳遞的内容是引用類型就是引用傳遞了,這跟實參的資料類型是沒有關系的,切切;其次,絕大部分同志都會認同這樣一個現象【3】:引用類型實參并沒有傳遞堆中實參本身,而是傳遞指向實參的棧中位址值,由此,“正确歸一了”java中隻有值傳遞的結論。由此,就必須先搞清楚引用傳遞的概念,其他的,就迎刃而解了。所謂引用傳遞,是指在調用函數時,将實參未經拷貝,不建立副本,直接傳遞到方法内部,自然,方法内形參改變一定會影響方法外實參。你一定會疑問:這不是跟【3】的表述一樣麼!不。【3】描述的隻是一個表象,java中引用類型向方法内傳遞的确實是一個位址值,但是,它隻是指向堆中實參的棧中位址值的一份拷貝,是一個副本,雖然這個副本位址值與原本位址值模樣一毛一樣,指向一毛一樣,能實作的功能(修改指向空間内的内容)一毛一樣,你在方法内讓這個副本位址值變成了另一位址值,指向另一個空間(比如為字元串賦新值,new Object()等),方法内一切正常,出了方法,這個實參位址值副本生命周期結束,被幹掉,它新指向的新的空間如果閑置,則等待垃圾回收,實參在棧中的原位址值被重新啟用,并保持原指向(雖然原指向空間中的内容可能已經改變)。懵不懵?看圖就懂了:

由指派中的“别名現象”引發的思考

    至此,我們隻要揪住“副本”這一核心,很多問題就清晰了(相信你回頭看看【1】和【2】會有新的收獲)。必須坦言,我在深度(JVM記憶體模型等)和廣度(C,C++等)上的了解是有限的,如有錯誤,敬請指正!

    以上。