一文搞懂參數傳遞原理
前言
最近一年多的時間陸續接觸了一些對我來說陌生的語言,主要就是 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=10
函數調用過程與之前的 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)。