iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 20
2
Software Development

活用python- 路遙知碼力,日久練成精系列 第 20

Day20- 可變變數的災難,太自由如脫疆野馬?

路遙知碼力,日久練成精- 只要在程式之路鑽研的夠深,便能夠充分發揮程式碼的力量; 練習的日子夠久,便能夠練成寫出精簡代碼的能力。

大家好,我是心原一馬,內心原來一心喜歡打程式碼。
人人都說python簡潔,
但也因python過於自由奔放的特性,
許多朋友也說python不如C/C++來的嚴謹,
也因而可能會暗藏許多意想不到的陷阱。
但是不要緊,既然我們都連續學習了二十天,
一起來克服它吧。

本篇要來談談一些python看似正常的語法卻令人意外的結果。

昨日課後進階學習討論(初學者建議直接略過)

好的,照慣例,我們依舊先討論一下昨天課後學習的解答,
(還沒看過題目的朋友歡迎點昨日題目傳送門)
小馬覺得本段談的東西並不那麼常用,
如果這一段看不懂也沒關係,
這段並非整個「路遙知碼力,日久練成精」的主軸,
太陽明天也依舊會升起,
等等覺得難以理解的話可直接略過此段看本文。

昨日問題是猜以下程式碼的執行結果:

mulFuncs = [lambda x: x*i for i in range(1,6)]
for func in mulFuncs:
    print(func(2))

直覺來說,列表生成式內的匿名函數計算的不就是x*1, x*2,..., x*5的值嗎?
因此你的預期結果應該是
2
4
6
8
10
吧?
但實際執行程式後,你卻會得到5個10:
10
10
10
10
10
驚訝不驚訝?
這就要從python中的變數查找規則LEGB(local, enclosed, global, bulid-in)說起了,
至於具體來說LEGB是什麼東西,
你可以參考namespace與LEGB scope規則這篇文章,
本文不詳加探討。
python使用變數時,會依照local, enclosed, global, bulid-in的順序找起,
lambda x: x*i這個函數中,i並沒有做為函數參數傳進函數中,
i並非一個local變數,只能往外在enclosed區域中找起,
for迴圈跑完時,i的值變成了5,
於是每個lambda x: x*i參考的值也成了i=5的值。
那如何修正呢?
在宣告變數時,使i成為一個loacl變數傳進函數就行,
如下:

mulFuncs = [lambda x, m=i: x*m for i in range(1,6)]
for func in mulFuncs:
    print(func(2))

(上述這段蠻進階的,若對你來說很困難直接略過沒關係的。)

正文: 可變變數的災難

那麼python中還有哪些隱藏的坑來其中呢?
在python裡面最常碰到的坑,
小馬覺得以可變變數list應該算榜上有名,
因為它可變的特性,有時候你在不經意的時候改到它的值,
導致陷入萬劫不復的深淵,
例如我們很久以前於Day3舉過的冰箱的的例子即為一例。

注意小馬這邊講的「坑」,
指的是程式碼看起來沒問題,但執行結果卻跟你想的不一樣,
如果是一些新手常碰到的問題:
如「縮排tab和空格混著用」、「忘記加冒號」,
我覺得都不算是真正的「坑」,
因為光執行程式都無法編繹過,
自然會知道程式碼是有錯的。

究竟python的坑常在哪裡出現,
又該如何避免踩坑,
以免寫出一個要花好幾個小時來找的程式錯誤呢?
以下一一介紹寫python應注意的陷阱。

一、可變變數的賦值錯誤

這個例子我們在Day3中已經講過,
這邊再次整理給大家:
<錯誤>

fridge = ['蛋糕', '蘋果', '香蕉']
b = fridge
b.remove('蛋糕')
print("列表b的內容為:", b)
print("列表fridge的內容為:", fridge)

結果為:
列表b的內容為: ['蘋果', '香蕉']
列表fridge的內容為: ['蘋果', '香蕉']
你希望bfridge是互不影響的變數,
修改了b的內容卻跟著影響了fridge的內容。

<修正>
將第二行程式改為:
b = fridge[:] 或者
b = fridge.copy()

二、將可變變數做為預設參數

假設你寫了一個函數,
希望將新的物件添加到列表中,
默認參數為空列表,
如下:

def add_to_list(obj, List=[]):
    List +=[obj]
    return List

看起來沒什麼問題。
測試以下執行結果:

print(add_to_list(5))
print(add_to_list(4))
print(add_to_list(3))

你心目中預期的結果應該會是
[5]
[4]
[3]

但實際出現的結果卻是
[5]
[5, 4]
[5, 4, 3]
驚喜不驚喜?
這意味著每次調用函數用的都是同一個list,
也就是函數創好的時候,
默認參數只會被產生一次,
而非你心目中想像的每呼叫一次函數變重新產生一次參數。
要避免這種陷阱,
最好用不可變變數做為參數:
建議解法:

def add_to_list(obj, List=None):
    if not List:
        List=[]
    List +=[obj]
    return List

三、不正確的產生二維陣列

我們都知道如何產生一個初始值為0,
長度為7的一維list,如下:

List= [0]*7
print(List)

結果為:
[0, 0, 0, 0, 0, 0, 0]
看起來蠻好的。

那要如何產生一個初始值為0,
大小為3*3的二維list呢?
相信很多人會舉一反三,寫出以下的程式碼:

List_2D = [[0]*3]*3
print(List_2D)

結果為:
[[0, 0, 0], [0, 0, 0], [0, 0, 0]]
乍看之下也沒什麼問題。
但是當我們嘗試修改list內的值時:

List_2D = [[0]*3]*3
List_2D[0][1] = 10
print(List_2D)

結果為:
[[0, 10, 0], [0, 10, 0], [0, 10, 0]]
咦?每一列的第一個元素竟然都被改掉了?
追根究柢原因為其實這三個list都貼上同一張標籤。
正確寫法為採用列表生成式以生成三個不同物件:

List_2D = [[0]*3 for i in range(3)]
List_2D[0][1] = 10
print(List_2D)

現在已經能正確顯示結果了:
[[0, 10, 0], [0, 0, 0], [0, 0, 0]]

四、在訪問列表的同時修改列表

enumerate可以很方便的訪問列表,
我們來看看正常的用法:

A= ['H', 'e', 'l', 'l', 'o']  
for i,e in enumerate(A):
    print(i,e)

結果為:
0 H
1 e
2 l
3 l
4 o

enumerate透過for加兩個變數迭代,
可以同時取得列表的index及內容,非常方便。

但如果我們嘗試邊訪問列表邊修改它的話,
可能就會發生意料之外的錯誤,例如:

A= ['H', 'e', 'X', 'Y', 'l','l','o']
for i,e in enumerate(A):
    if e=='X' or e=='Y':
        del A[i]
print(A)

在此例中,我們希望把列表中的'X'字元及'Y'刪除,
但印出來的結果是:
['H', 'e', 'Y', 'l', 'l', 'o']
咦?只有刪掉一個耶?怎麼回事?
我們把迭代過程中的i,e印出來會明白一些:

A= ['H', 'e', 'X', 'Y', 'l','l','o']
for i,e in enumerate(A):
    print(i,e)
    if e=='X' or e=='Y':
        del A[i]

結果為:
0 H
1 e
2 X
3 l
4 l
5 o
哦哦,原來如此,index在遞增,但列表變短了,
'X'這個字被刪除後,本來在index「3」位置的'Y'字元就跑到「2」這個位置了,
但是for迴圈繼續往下走,故沒有看到'Y'這個字呢。

<修正>:
當邏輯簡單時,可以直接用列表生成式來解,
例如:

A= ['H', 'e', 'X', 'Y', 'l','l','o']
A= [c for c in A if c!='X' and c!='Y']
print(A)

結果為:
['H', 'e', 'l', 'l', 'o']

五、對可變變數來說,x=x+y與x+=y意義其實不同

在某些初級教程中,你可能會看到說x+=y就是x=x+y的簡潔寫法,
嗯…對初學者來說是可以暫時把它們想成是相同的比較單純,
但事實上,對可變變數來說,這兩者之間還是有些微妙的差異,
請看例子:

x = [1,2,3]
z = x
x += [4]
print("列表x為", x)
print(z)

第一支程式創建了一個列表,名字叫做x
這時在拿一張名字為z的標籤貼上去,
因此x修改時,z也跟著受影響。
結果為
列表x為 [1, 2, 3, 4]
[1, 2, 3, 4]

我們再看一支很像的程式,
只是把x += [4]改成x = x+[4]:

x = [1,2,3]
z = x
x = x+[4]
print("列表x為", x)
print(z)

結果為
列表x為 [1, 2, 3, 4]
[1, 2, 3]
此時列表z不受影響了耶,
可以想成是用=賦值的時候真的創建了一個新的物件,
而使用+=的時候則是原地修改x這個列表,
故使用+=時會跟著影響z

以上便是python語法中比較常見的一些陷阱了,
今日無課後練習,
我們明天再見囉。

參考資料

  1. 程式設計師老司機都要錯的 Python 陷阱與缺陷列表
  2. 學習編寫 Python 時應該避免的三種錯誤

上一篇
Day19- 隨心所欲的自定義函數參數介紹
下一篇
Day21- 黑魔法,recursion,recursion depon (遞迴函數的介紹)
系列文
活用python- 路遙知碼力,日久練成精30

2 則留言

1
ovenchang
iT邦新手 5 級 ‧ 2020-04-23 15:22:30

釐清很多觀念 感謝

心原一馬 iT邦研究生 5 級 ‧ 2020-04-23 15:28:19 檢舉

謝謝您的欣賞哦~

0
sowhat1124
iT邦新手 5 級 ‧ 2020-09-29 19:32:37

您好,想請問我的想法哪邊不對呢?

def add_to_list(obj, List=None):
    if not List:
        List=[]
    List +=[obj]
    return List

print(add_to_list(3))

>>> [3]

以上程式碼中,
print(add_to_list(3)) 沒有輸入 List 參數,
所以 List == None ,if 條件應該不會執行,
也就是 List 仍然是 None ,而不是 [] ,
List+[obj] 應該要報錯才對,但實際上會得到 [3] ,
請問我哪裡想錯了呢?

我自己想到了, 因為 not List 為 True ,所以默認參數下 if 都會執行。
剛剛不知道為甚麼一直想不到 XD

我要留言

立即登入留言