1. 程式人生 > >關於函式引數傳遞,80%人都錯了

關於函式引數傳遞,80%人都錯了

還記得上一次關於變數作用域文章 :

Crossin:全菊變數和菊部變數zhuanlan.zhihu.com圖示

我們在公眾號(Crossin的程式設計教室)裡做了個問題投票:

def func(m):
    m[0] = 20
    m = [4, 5, 6]
    return m

l = [1, 2, 3]
func(l)
print('l =', l)

實際的輸出我想大家都嘗試過了吧,應該是選項二:[20, 2, 3]

和80%人想象中的結果不一樣。

這是為什麼呢?

在 Python 的官方文件 FAQ 裡有這樣一句話

Remember that arguments are passed by assignment in Python.要記住,Python 裡的引數是通過賦值傳遞的。

所以要弄清楚引數傳遞,先得弄清 Python 的賦值。

或許在很多人的直觀印象中,變數是一個容器;給變數賦值,就像是往一個儲存的容器中填入一個數據;再次賦值就是把容器中的資料換掉。

然而,

在 Python 中,這種理解是不準確的!在 Python 中,這種理解是不準確的!在 Python 中,這種理解是不準確的!

若是想要個形象的類比,Python 中的變數更像是是個標籤;給變數賦值,就是把標籤貼在一個物體上;再次賦值就是把標籤貼在另一個物體上

體會下這兩種設計的差異:

· 前者,變數是一個固定的存在,賦值只會改變其中的數值,而變數本身沒有改動。· 後者,變數不存在實體,它僅僅是一個標籤,一旦賦值就被設定到另一個物體上,不變的是那些物體。

這些“物體”就是物件Python 中所有東西都是物件,包括函式、類、模組,甚至是字串’hello’,數字1、2、3,都是物件。

用個例子來說明:

a = 1
b = 2
c = 1
# 再次賦值
a = b

在這個程式碼裡,a 和 c 其實指向的是同一個物件—整數 1。給 a 賦值為 b 之後,a 就變成了指向 2 的標籤,但 1 和 c 都不會受影響。

示意圖:

更有說服力一點的驗證:

a = 1
print('a', a, id(a))
b = 2
print('b', b, id(b))
c = 1
print('c', c, id(c))
# 再次賦值
a = b
print
('a', a, id(a))

輸出:

a 1 4301490544
b 2 4301490576
c 1 4301490544
a 2 4301490576

id() 可以認為是獲取一個物件的地址。可以看出,a 和 c 開始其實是同一個地址,而後來賦值之後,a 又和 b 是同一個地址。

每次給變數重新賦值,它就指向了新的地址,與原來的地址無關了。

回到函式的呼叫上:Python 裡的引數是通過賦值傳遞的

def fn(x):
    x = 3

a = 1
fn(a)
print(a)

輸出結果為 1,a 沒有變化。

呼叫 fn(a) 的時候,就相當於做了一次 x = a,把 a 賦值給了 x,也就是把 x 這個標籤貼在了 a 的物件上。只不過 x 的作用域僅限於函式 fn 內部。

當 x 在函式內部又被賦值為 3 時,就是把 x 又貼在了 3 這個物件上,與之前的 a 不在有關係。所以外部的 a 不會有任何變化。

把其中的數值換成其他物件,效果也是一樣的:

def fn(x):
    x = [4,5,6]

a = [1,2,3]
fn(a)
print(a)

輸出結果為 [1,2,3],a 沒有變化。(記住這個例子,最後我們還會提到)

那上次的題目又是怎麼回事?

我們再來看一個賦值:

a = [1,2,3]
print('a', a, id(a))
b = a
print('b', b, id(b))
b[1] = 5
print('a', a, id(a))
print('b', b, id(b))

輸出:

a [1, 2, 3] 4490723464
b [1, 2, 3] 4490723464
a [1, 5, 3] 4490723464
b [1, 5, 3] 4490723464

這個是不是好理解一點?b 賦值為 a 後,和 a 指向同一個列表物件。[1] 這個基於 index 的賦值是 list 物件本身的一種操作,並沒有給 b 重新貼標籤,改變的是物件本身。所以 b 指向的還是原來的物件,此物件的改動自然也會體現在 a 身上。同理,b.append(7) 這樣的操作也會是類似的效果。

再來回顧下原問題呢:

def func(m):
    m[0] = 20
    # m = [4, 5, 6]
    return m

l = [1, 2, 3]
func(l)
print('l =', l)

去掉那句 m=[4,5,6] 的干擾,函式的呼叫就相當於:

l = [1, 2, 3]
m = l
m[0] = 20

l 的值變成 [20,2,3] 沒毛病吧。而對 m 重新賦值之後,m 與 l 無關,但不影響已經做出的修改。

這就是這道題的解答。上次留言裡有些同學已經解釋的很準確了。

另外說下,函式的返回值 return,也相當於是一次賦值。只不過,這時候是把函式內部返回值所指向的物件,賦值給外面函式的呼叫者:

def fn(x):
    x = 3
    print('x', x, id(x))
    return x

a = 1
a = fn(a)
print('a', a, id(a))

輸出:

x 3 4556777904
a 3 4556777904

函式結束後,x 這個標籤雖然不存在了,但 x 所指向的物件依然存在,就是 a 指向的新物件。

所以,如果你想要通過一個函式來修改外部變數的值,有幾種方法:

  1. 通過返回值賦值
  2. 使用全域性變數
  3. 修改 list 或 dict 物件的內部元素
  4. 修改類的成員變數

有相當多的教程把 Python 的函式引數傳遞分為可變物件和不可變物件(這個概念下次來說)來說明,然後類比到 C 的值傳遞和引用傳遞。我很反對這樣去理解:

  1. 對於沒有學過 C 的人來說,這個解釋屬於迴圈論證,還是沒說清問題。
  2. Python 本來就不存在值傳遞/引用傳遞的概念,這個比較沒有意義。
  3. 這個類比實際上是錯誤的。就算類比,也應該是相當於 C 裡的指標值傳遞。
  4. 用可變物件/不可變物件來劃分很容易產生誤解,比如我們前面例子中的 x=[4,5,6],它是可變物件,但一樣不影響外部引數的值。

這點前面貼出的官方文件裡也直說了:

Since assignment just creates references to objects, there’s no alias between an argument name in the caller and callee, and so no call-by-reference per se.賦值是建立了一份物件的引用(也就是地址),形參和實參之間不存在別名的關係,本質上不存在引用傳遞。

網上很容易搜到“引數是可變物件就相當於引用傳遞”這種錯誤的理解。也不知道他們是對 Python 的引數傳遞有什麼誤解,還是對C 的引用傳遞有什麼誤解。結果就是,讓很多初學者從網上看了幾篇教程之後,更糊塗了。

所以呢,找到一個靠譜的教程是非常重要滴

════其他文章及回答:

歡迎搜尋及關注:Crossin的程式設計教室