一文搞懂參數傳遞原理

前言

最近一年多的時間陸續接觸了一些對我來說陌生的語言,主要就是 PythonGo,期間為了快速實現需求只是依葫蘆畫瓢的擼代碼;並沒有深究一些細節與原理。

就拿參數傳遞一事來說各個語言的實現細節各不相同,但又有類似之處;在許多新手入門時容易搞不清楚,導致犯一些低級錯誤。

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 從圖中可以看出這是 aaa 兩個變量沒有一毛錢關係,所以對 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));
    }

在這個例子里先創建了一個 benzcar1,通過一個方法修改為 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 的值是被方法修改了,這點便是 GoJava 很大的不同點:

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 這個參數內存地址的拷貝;他們都指向了同一塊內存地址。

所以對這塊數據的修改本質上改的是同一份數據,但一旦重新賦值就會創建一塊新的內存從而不會影響到原始數據。

image.png

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)。