關於函式引數傳遞,80%人都錯了
還記得上一次關於變數作用域文章 :
Crossin:全菊變數和菊部變數我們在公眾號(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 指向的新物件。
所以,如果你想要通過一個函式來修改外部變數的值,有幾種方法:
- 通過返回值賦值
- 使用全域性變數
- 修改 list 或 dict 物件的內部元素
- 修改類的成員變數
有相當多的教程把 Python 的函式引數傳遞分為可變物件和不可變物件(這個概念下次來說)來說明,然後類比到 C 的值傳遞和引用傳遞。我很反對這樣去理解:
- 對於沒有學過 C 的人來說,這個解釋屬於迴圈論證,還是沒說清問題。
- Python 本來就不存在值傳遞/引用傳遞的概念,這個比較沒有意義。
- 這個類比實際上是錯誤的。就算類比,也應該是相當於 C 裡的指標值傳遞。
- 用可變物件/不可變物件來劃分很容易產生誤解,比如我們前面例子中的
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的程式設計教室