前言
最近一年多的時間陸續接觸了一些對我來說陌生的語言,主要就是 Python 和 Go ,期間為了快速實現需求只是依葫蘆畫瓢的擼程式碼;並沒有深究一些細節與原理。
就拿引數傳遞一事來說各個語言的實現細節各不相同,但又有類似之處;在許多新手入門時容易搞不清楚,導致犯一些低階錯誤。
Java基本型別傳遞先拿我最熟悉的 Java 來說,我相信應該沒人會寫這樣的程式碼:
@Test public void testBasic() { int a = 10; modifyBasic(a); System.out.println(String.format("最終結果 main a==%s", a)); } private void modifyBasic(int aa) { System.out.println(String.format("修改之前 aa==%s", aa)); aa = 20; System.out.println(String.format("修改之後 aa==%s", aa)); }
輸出結果:
修改之前 aa==10修改之後 aa==20最終結果 main a==10
不過從這段程式碼的目的來看應該是想要修改 a 的值,從直覺上來說如果修改成功也是能理解的。
至於結果與預期不符合的根本原因是理解錯了引數的值傳遞與引用傳遞。
在這之前還是先明確下值傳遞與引用傳遞的區別:
這裡咱們先丟擲結論, Java 採用的是值傳遞;這樣也能解釋為什麼上文的例子沒有成功修改原始資料。
參考下圖更好理解:
當發生函式呼叫的時候 a 將自己傳入到 modifyBasic 方法中,同時將自己的值複製了一份並賦值給了一個新變數 aa 從圖中可以看出這是 a 和 aa 兩個變數沒有一毛錢關係,所以對 aa 的修改並不會影響到 a 。
有點類似於我把蘋果給了老婆,她把蘋果削好了;但我手裡這顆並沒有變化,因為她只是從餐盤裡拿了一顆一模一樣的蘋果削好了。
如果我想要她那顆,只能讓她把削好的蘋果給我;也就類似於使用方法的返回值。
a = modifyBasic(a);
引用型別傳遞
下面來看看引用型別的傳遞:
private class Car{ private String name; public Car(String name) { this.name = name; } @Override public String toString() { return "Car{" + "name='" + name + '\'' + '}'; } } @Test public void test01(){ Car car1 = new Car("benz"); modifyCar1(car1); System.out.println(String.format("最終結果 main car1==%s", car1)); } private void modifyCar1(Car car){ System.out.println(String.format("修改之前 car==%s", car)); car.name = "bwm"; System.out.println(String.format("修改之後 car==%s", car)); }
在這個例子裡先建立了一個 benz 的 car1 ,透過一個方法修改為 bmw 那最開始的 car1 會受到影響嘛?
修改之前 car==Car{name='benz'}修改之後 car==Car{name='bwm'}最終結果 main car1==Car{name='bwm'}
結果可能會與部分人預期相反,這樣的修改卻是可以影響到原有資料的?這豈不是和 值傳遞 不符,看樣子這是 引用傳遞 吧?
別急,透過下圖分析後大家就能明白:
在 test01 方法中我們建立了一個 car1 的物件,該物件存放於堆記憶體中,假設記憶體地址為 0x1102 ,於是 car1 這個變數便應用了這塊記憶體地址。
當我們呼叫 modifyCar1 這個方法的時候會在該方法棧中建立一個變數 car ,接下來重點到了:
這個 car 變數是由原本的入參 car1 複製而來,所以它所對應的堆記憶體依然是 0x1102 ;
所以當我們透過 car 這個變數修改了資料後,本質上修改的是同一塊堆記憶體中的資料。從而原本引用了這塊記憶體地址的 car1 也能檢視到對應的變化。
這裡理解起來可能會比較繞,但我們記住一點就行:
傳遞引用型別的資料時,傳遞的並不是引用本身,依然是值;只是這個 值 是 記憶體地址 罷了。
因為把相同的記憶體地址傳過去了,所以對資料的操作依然會影響到外部。
所以同理,類似於這樣的程式碼也會影響到外部原始資料:
@Test public void testList(){ List<Integer> list = new ArrayList<>(); list.add(1); addList(list); System.out.println(list); } private void addList(List<Integer> list) { list.add(2); } [1, 2]
那如果是這樣的程式碼:
@Test public void test02(){ Car car1 = new Car("benz"); modifyCar(car1); System.out.println(String.format("最終結果 main car1==%s", car1)); } private void modifyCar(Car car2) { System.out.println(String.format("修改之前 car2==%s", car2)); car2 = new Car("bmw"); System.out.println(String.format("修改之後 car2==%s", car2)); }
假設 Java 是引用傳遞那最終的結果應該是列印 bmw 才對。
修改之前 car2==Car{name='benz'}修改之後 car2==Car{name='bmw'}最終結果 main car1==Car{name='benz'}
從結果又能佐證這裡依然是值傳遞。
如果是引用傳遞,原本的 0x1102 應該是被直接替換為新建立的 0x1103 才對;而實際情況如上圖所示, car2 直接重新引用了一個物件,兩個物件之間互不干擾。
Go相對於 Java 來說 Go 的用法又有所不同,不過我們也可以先得出結論:
Go語言的引數也是值傳遞。
在 Go 語言中資料型別主要有以下兩種:
值型別與引用型別;
值型別先以值型別舉例:
func main() { a :=10 modifyValue(a) fmt.Printf("最終 a=%v", a)}func modifyValue(a int) { a = 20}輸出:最終 a=20
函式呼叫過程與之前的 Java 類似,本質上傳遞到函式中的值也是 a 的複製,所以對其的修改不會影響到原始資料。
當我們把程式碼稍加修改:
func main() { a :=10 fmt.Printf("傳遞之前a的記憶體地址%p \n", &a) modifyValue(&a) fmt.Printf("最終 a=%v", a)} func modifyValue(a *int) { fmt.Printf("傳遞之後a的記憶體地址%p \n", &a) *a = 20}傳遞之前a的記憶體地址0xc0000b4040 傳遞之後a的記憶體地址0xc0000ae020最終 a=20
從結果來看最終 a 的值是被方法修改了,這點便是 Go 與 Java 很大的不同點:
在 Go 中存在著指標的概念,我們可以將變數透過指標的方式傳遞到不同的方法中,在方法裡便可透過這個指標訪問甚至修改原始資料。
那這麼一看不就是引用傳遞嘛?
其實不然,我們仔細看看剛才的輸出會發現引數傳遞前後的記憶體地址並不相同。
傳遞之前a的記憶體地址0xc0000b4040 傳遞之後a的記憶體地址0xc0000ae020
這也恰好論證了值傳遞,因為這裡實際傳遞的是指標的複製。
也就是說 modifyValue 方法中的引數與入參的 &a 都是同一塊記憶體的指標,但指標本身也是需要記憶體來存放的,所以在方法呼叫過程中新建了一個指標 a ,從而導致他們的記憶體地址不同。
雖然記憶體地址不同,但指向的資料都是同一塊,所以方法內修改後原始資料也受到了影響。
引用型別對於 map slice channel 這類引用型別又略有不同:
func main() { var personList = []string{"張三","李四"} modifySlice(personList) fmt.Printf("slice=%v \n", personList)}func modifySlice(personList []string) { personList[1] = "王五"}slice=[張三 王五]
最終我們會發現原始資料也被修改了,但我們並沒有傳遞指標;同樣的特性也適用於 map 。
但其實我們檢視 slice 的原始碼會發現存放資料的 array 就是指標型別:
type slice struct { array unsafe.Pointer len int cap int}
所以我們可以直接對資料進行修改,相當於間接的帶了指標。
使用建議那我們在什麼時候使用指標呢?有以下幾點建議:
int,float
Python
在 Python 中變數是否可變是影響引數傳遞的重要因素:
如上圖所示, bool int float 這些不可變型別在引數傳遞過程中是不能修改原始資料的。
if __name__ == '__main__': x = 1 modify(x) print('最終 x={}'.format(x)) def modify(val): val = 2最終 x=1
原理與 Java Go 中類似,是基於值傳遞的,這裡就不再複述。
這裡重點看看可變資料型別在引數傳遞中的過程:
if __name__ == '__main__': x = [1] modify(x) print('最終 x={}'.format(x)) def modify(val): val.append(2)最終 x=[1, 2]
最終資料受到了影響,那麼就表明這是引用傳遞嘛?再看個例子試試:
if __name__ == '__main__': x = [1] modify(x) print('最終 x={}'.format(x)) def modify(val): val = [1, 2, 3]最終 x=[1]
顯而易見這並不是引用傳遞,如果是引用傳遞最終 x 應當等於 [1, 2 ,3] 。
從結果來看這個傳遞過程非常類似 Go 中的指標傳遞, val 拿到的也是 x 這個引數記憶體地址的複製;他們都指向了同一塊記憶體地址。
所以對這塊資料的修改本質上改的是同一份資料,但一旦重新賦值就會建立一塊新的記憶體從而不會影響到原始資料。
與 Java 中的上圖類似。
所以總結下:
對於不可變資料:在引數傳遞時傳遞的是值,對引數的修改不會影響到原有資料。對於可變資料:傳遞的是記憶體地址的複製,對引數的操作會影響到原始資料。這麼說來這三種都是值傳遞了,那有沒有引用傳遞的語言呢?
當然, C++ 是支援引用傳遞的:
#include <iostream>using namespace std; class Box{ public: double len;};void modify(Box& b); int main (){ Box b1; b1.len=100; cout << "呼叫前,b1 的值:" << b1.len << endl; modify(b1); cout << "呼叫後,b1 的值:" << b1.len << endl; return 0;} void modify(Box& b){ b.len=10.0; Box b2; b2.len = 999; b = b2; return;}呼叫前,b1 的值:100呼叫後,b1 的值:999
可以看到把新物件 b2 賦值給入參 b 後是會影響到原有資料的。
總結其實這幾種語言看下來會發現他們中也有許多相似之處,所以通常我們在掌握一門語言後也能快速學習其他語言。
但往往是這些基礎中的基礎最讓人忽略,希望大家在日常編碼時能夠考慮到這些基礎知識多想想一定會寫出更漂亮的程式碼(bug)。
出處:https://segmentfault.com/a/1190000038940082