Python 技術者們 - 練功!Python 技術者們 - 練功!

2024/06/30:更新三部曲系列的最後一篇!——〈《Python 功力提升的樂趣》心得總結:掌握 Clean Code 基本功〉。

2023/12/30:重新編輯全文、刪除部分內容,使文章更緊湊、好讀。調整段落順序並新增「本書目標讀者」一節。

要說現在哪一門程式語言的入門書最多?恐怕就是 Python 了吧?

基於動態語言容易上手的特性,加上大數據、人工智慧的推波助瀾,Python 的熱度是一年比一年升溫,相關書籍也是一本接著一本推出。看看天瓏書局的 Python 分類,可謂汗牛充棟,多到讓你懷疑人生。

即使扣除特定領域應用和相對進階的內容,剩下通盤介紹 Python 基礎的入門書,無論本土還是翻譯作,加起的數量依舊可觀,這也成為了初學者入門選書時的一大困擾

少則得,多則惑」,本系列將繼續介紹,我認為值得一讀的 Python 入門佳作。

不求多,但求讓你讀完這 3 本書後,可以對 Python 有足夠的基本理解、避免常見的新手錯誤,更重要的是——能寫出「Pythonic」的程式碼。

系列:Python 入門三部曲

  1. 《精通 Python 第二版》心得:給入門者的 Python 學習藍圖
  2. 最佳 Python 入門書——《Python 技術者們 - 練功!》心得與導讀
  3. 《Python 功力提升的樂趣》心得總結:掌握 Clean Code 基本功

本文目錄

  1. 本文主旨與閱讀建議
  2. 本書目標讀者
  3. 超越《精通 Python》的入門傑作
  4. 優於《精通 Python》的內容編排
  5. 實用性滿滿的「老手帶路」
  6. 一、Python 中的變數:是水桶還是標籤?
  7. 二、使用可變物件作為引數時要小心
  8. 三、小心區域變數未賦值先使用
  9. 書中的經典案例
  10. 全書總評
  11. 結語:再會了!新手村

本文主旨與閱讀建議

在這篇文章中,我將從 Python 開發者的角度,深入探討《Python 技術者們 - 練功!》這本書的核心概念,分享我的看法、心得與開發經驗。

閱讀本文前的建議

本文預設讀者對 Python 基礎語法已有初步的了解。如果你是初學者,建議先讀完上述的《精通 Python》或系列第一篇文章,再回來閱讀本文。

本書目標讀者

換言之,我認為本書並不適合完全沒有 Python 基礎的讀者,因為它並不是一本「從零開始」的入門書——但也絕非 Python 進階書籍,只是沒那麼適合「純新手」而已。

Python 開發者亦值得一讀

值得一提的是,即使對於已經使用 Python 一至兩年的開發者,本書的內容依然有一定價值。本文後續內容,將會證明這一點。

它不僅是初學者的寶貴資源,更是對 Python 開發者的重要提醒。讓我們重新審視自己對 Python 的理解,並且挖掘出那些在日常開發中可能被忽略的概念和細節


好,有了上面的理解,我們來開始探索本書的迷人之處。先從輪廓與定位談起。

超越《精通 Python》的入門傑作

正如我上一篇心得所言:

《精通 Python》可能不是我心中最好的 Python 入門書,但仍不失為一本經典之作。

沒錯,因為我心中最佳的 Python 入門書,就是本書

如果說《精通 Python》是一部佳作,那麼這本《Python 技術者們 - 練功!老手帶路教你精通正宗 Python 程式》則屬毫無疑問的傑作——用詞淺白,卻能直指痛處

除了翻譯的品質明顯比《精通 Python》更好,作者 Naomi Ceder 也大有來頭,曾經是 Python 軟體基金會(Python Software Foundation)的主席。

雖然從銷量與知名度而言,本書和《精通 Python》有一定的差距。

優於《精通 Python》的內容編排

上篇我還提到《精通 Python》的一個明顯缺點,就是「第二部分所佔比例太高」:

簡單來說,我認為第二部分的篇幅有點太多了,減半可能會更理想。因為每一項都只能蜻蜓點水般介紹,容易讓人感覺零碎與不連貫,同時分散了學習的注意力。

而本書的篇幅分配則較為合理——Python 主體介紹佔全書 2/3,其餘套件應用部分則佔 1/3。兩相比較,我認為《精通 Python》各半的配置,對於「入門書」這個定位而言,難免有「喧賓奪主」之嫌。

本書合宜的內容編排,所帶來的閱讀與學習體驗提升,我認為是明顯的——全書讀起來更加緊湊、環環相扣,幾乎沒什麼多餘的章節。

當然,這只是一種取捨,沒有絕對的優劣,只是我更喜歡本書的做法。

實用性滿滿的「老手帶路」

本書最大的亮點,無疑是適時穿插於章節之中的「老手帶路」專欄。

篇如其名,它總結了一些 Python 學習與實作上,容易誤解或發生錯誤的地方。除了讓你預先對這些易於混淆的概念有個通盤理解,同時也從正反兩面相互比較,讓學習思路更加清晰,堪稱觀念與實用兼具。

甚至,如果你不知道哪些章節是重點,只要翻閱目錄,看看哪幾章的「老手帶路」環節特別多——那一章肯定是重要的。


接下來就舉書中 3 個我特別有感的重要觀念——且三者環環相扣、層層遞進——作為本書的導讀。

一、Python 中的變數:是水桶還是標籤?

參見本書:頁 4-9。

這是一個重要且影響無所不在的概念。

把變數和「變數值」的關係,想像成水桶和「水桶中的水」,無疑是符合人類直覺的思維——然而 Python 並非如此。Python 中的變數,就像 Git 的分支一樣,都只是一張「貼紙(標籤)」,而變數的實際內容,則另有參照主體——物件

換句話說,在 Python 中,我們只是把變數這張貼紙,貼到「物件」上面而已。

比如 number = 3,number 是一個變數,而 3 是一個獨立的整數物件,是這個 = 把兩者暫時綁定(參照)在一起了。但 3 依舊不是 number 的一部分,只是 number 變數的「參考(reference)」。

為什麼這個觀念很重要?

因為如果 Python 變數是水桶,則理論上每一個變數的值,應該是「各自獨立,互不影響」的存在,但實際並非如此,這就是水桶和標籤的主要差異。

實例說明

想要驗證變數是標籤而非水桶,最典型的例子,就是當多個變數同時參考(參照)到同一個可變物件(比如 list)時:

1
2
3
4
a = [1 ,2 ,3]
c = b = a
c[1] = 5
print(a, b, c)

上面的 a、b、c,實際上都參考了同一個可變物件 [1 ,2 ,3](標籤說),而不是各自擁有自己變數值(水桶說)。

因此,print 輸出結果會是:

1
>>> [1, 5, 3], [1, 5, 3], [1, 5, 3]

上面第 3 行程式碼 c[1] = 5,僅僅是變更了 c 變數的內容,結果卻是 a、b、c 三者都受到了影響,正是因為 a、b、c 的參照,其實都是指向(參照)同一個物件所致。

此外,為何說 Python「變數即標籤」特性影響無所不在?因為只有理解了這個特性,才能進一步理解它所衍生出來的各種 Python 行為特徵。

其中最具代表性的,就屬接下來要登場的「使用可變物件作為引數」。

二、使用可變物件作為引數時要小心

參見本書:頁 9-16。

函式在呼叫時,其引數傳入的是物件的參照,然後參數就會參照到引數所參照的物件。請注意!這時候引數與參數所參照的是同一個同件

本段引述可謂超級重要!

在理解這段內容之前,我們得先弄清楚,函式中參數和引數之間的差異

參數

參數代表程式預期您在呼叫它時傳遞的值。 程式的宣告會定義其參數。

每個參數的名稱都會當做程式中的區域變數 。 使用參數名稱的方式與使用任何其他變數的方式相同。

引數

引數代表您在呼叫程式時傳遞至程式參數的值。 呼叫程式碼會在呼叫程式時提供引數。

上述內容簡單理解就是:函式定義時,預先命名且代表引數傳入函式內部的區域變數,是為參數;而函式呼叫時實際傳入的變數,則為引數


回到正題,本段開頭所引述書上的短短幾行,其中資訊量可一點也不少,足以讓你舉一反三,推導出下列有關 Python 變數的 3 個重要事實

  1. 引數傳入後,如果函式內的參數重新參照了新物件(根據前述,引數和參數本來是參照同一個物件),由於重新參照,兩者的參照對象不再相同,所以作為引數的外部變數將不再受到影響
  2. 但是,如果傳入的引數是「可變物件(mutable」,且在函式內「修改參數的內容」,就會同時修改到函式外的引數——因為它們參照的是同一個物件
    1. 這是典型的「副作用」,要盡可能避免。
    2. 如果引數是「不可變物件(immutable)」,則不會有此問題。因為不可變物件,特性上就是無法被修改。所有對其進行的操作,都是重新參照,而不是修改原物件。
  3. 第三,是這段話中沒有特別強調,但能夠推論得出的命題——Python 引數的傳入模式為「Call by Object Reference」,有興趣的讀者可參考這篇〈Python 是 Pass By Value, Pass by Reference, 還是 Pass by Sharing?〉,文中有詳細的例示與說明,限於篇幅,這裡就不展開。

實例

用書中實例來說明上述概念:

1
2
3
4
5
6
7
8
9
10
11
def func(num, list1, list2):
num = 3
list1 = [4, 5, 6]
list2[0] = [4, 5, 6]

x = 5
y = [1 ,2 ,3]
z = [1 ,2 ,3]
func(x, y, z)

print(x, y, z, sep='\n')

為了方便辨識,我用 sep='\n'print 的每一個結果換行,實際輸出如下:

1
2
3
5
[1, 2, 3]
[[4, 5, 6], 2, 3]

x、y、z 變數狀態解說

  1. x 在函式外部的值為 5,型別是 int,屬於不可變物件。作為引數傳入函式中,因為在函式中「重新參照(重新賦值)」了新物件 3。
    1. 依上述說明,重新參照時,外部的變數不受影響,其值仍為原來的 5。
    2. 無論引數是可變或不可變物件,只要函式內部只是重新參照而非修改內容,外部變數都不受影響。
  2. 同理,y 傳入函式後為參數 list1,雖然 y 是「可變物件」,但其在函式內部的行為也只是重新參照(重新賦值)為 [4, 5, 6],所以外部變數 y 也不受影響。
  3. z 就不同,list2[0] = [4, 5, 6] 並非重新賦值,而是對其內容進行修改。前面已經提過,因為引數與參數所參照的是「同一個物件」,對參數的修改,相當於「對共同參照物件的修改」。結果就是——外部的 z 跟著一起改變!

若稍微修改一下上面程式碼的內容,把這兩行:

1
2
y = [1 ,2 ,3]
z = [1 ,2 ,3]

換成這一行「z = y = [1 ,2 ,3]」,則輸出的結果會有所不同

1
2
3
5
[[4, 5, 6], 2, 3]
[[4, 5, 6], 2, 3]

為什麼會這樣?參考上一段「Python 中的變數:是水桶還是標籤?」的例子,相信就不難明白。

同樣經典的,還有「不要使用可變物件作為預設參數」議題,可參考書中的頁 9-19,或是下面的相關文章。

相關文章:如何寫出 Pythonic 程式碼——《Python 功力提升的樂趣》


三、小心區域變數未賦值先使用

參見本書:頁 9-23。

這也一個要先理解「Python 變數即標籤」才容易切入的問題,所以最開頭才會有所謂的「影響深遠」之說,而這一頁絕對是我重翻本書最多次的一頁!

開發人員對於 Python 區域變數常常是一知半解

要說 Python 簡單,那恐怕只是因為我們知道的還太少。就像我本來也以為 Django 很簡單一樣。😂

即便是使用 Python 一段時間的開發人員,還是很可能對函式內的區域變數只有模糊的概念,因而寫出一些乍看有點摸不著頭緒的內容,比如下面的程式碼:

1
2
3
4
5
6
7
8
def get_total(list1):
"""取得傳入的list中的數值總合,如果為空list,則拋出錯誤"""
total = None
if list1:
total = sum(list1)
else:
raise ValueError("Empty list values!")
return total

這是一個我在 code review 時很常遇到的問題,而且每隔一段時間就會反覆出現——total = None 這行純屬多餘。

在本例中,並不需要給定 total 變數的預設值。換句話說,即使沒有 total = None 這一行,函式的行為也不會受到影響。

在此我們先不考慮 else 其實可以用衛語句(guard clause)替代,且假設 list1 參數的元素內容不會有型別錯誤。以此為前提,我們只關注一個核心問題

為什麼開發人員會覺得在 if/else 判斷式之前,應該要先有一行 total = None 作為預設值?

未賦值先使用?

當你詢問對方為何要加上這一行,得到的回答通常是:

因為怕產生未賦值先使用的錯誤。

但其實這裡不會。

會產生如此誤解的原因是:else 裡面雖然缺少關於 total 的賦值行為,但因為是直接拋出錯誤,進了 else 後就不會觸發函式的 return——不會有機會使用到 total

換句話說,return 只可能發生if 成立時。而 if 內確實有 total 的賦值行為,所以根本不會出現開發人員所擔心的未賦值先使用(UnboundLocalError):

1
2
# 不會發生,只是給你看一下想像中的錯誤訊息
UnboundLocalError: local variable 'total' referenced before assignment

反之,假設 else 內沒有 raise,而是做了一些事,但不包括 total 的賦值,那進了 else 後,最後要 return 時,才會出現上述「未賦值先使用」問題。

很小兒科的問題吧?但很遺憾,對新手 Python 工程師而言,它真的不算罕見。


開發人員沒有明確區分上述可能的不同情況,而一律加上預設值,往往是因為概念不夠清晰,或未能進一步思考使然。

或許你會問:「即使一律都加上預設值,也不會真的造成什麼大問題啊?只是程式碼有點冗餘而已」,這正是我們第三本書所要討論的議題,在此先按下不表。


書中的經典案例

上述開發人員如果看過接下來要介紹的,本書的經典案例,相信就會明白為何 total 變數不需要預設值。

這個「老手帶路」範例,就像書中所述,是連老手都可能失足的所在。在此我們先來看一段關鍵引言:

只要函式內部(不論位置、前後順序)對該變數有賦值動作,該變數一律視為區域變數

再看看書中的兩個實例,必能對這段引言有更深刻的理解,進而避免錯誤發生。

實例一

1
2
3
4
5
a = 'one'
def func():
print(a)
a = 1
func()

執行上述程式碼,你將得到熟悉的:

1
UnboundLocalError: local variable 'a' referenced before assignment

理由是函式中有 a = 1 這個賦值行為,且先未使用 global 宣告,則無論 a 在函式中的任何地方,都會被視為區域變數

儘管 a = 1 出現的順序在 print(a) 之後——以致於可能讓你覺得 print(a) 好像可以印出「one」字串(其實不然)——仍不影響 a 早已被視為區域變數的事實。

換句話說,在這個情況下,區域變數 a 必須先賦值再使用,且這個賦值必須先於任何取值行為,才不會發生錯誤。

本例中,print(a) 是典型的取值行為,而賦值卻在之後才發生,自然會出現「未賦值先使用」的錯誤。

實例二

實例一的簡單變形,讓你更清楚其中的概念,程式碼如下:

1
2
3
4
5
6
7
8
a = 'one'
def func(x):
if x:
a = 1
print(a)

func(True)
func([])

上面的 True 可以任意替換為 1、非空字串等等會被視為 True 的值;[] 亦然,可以替換為 False0 等等。

這個例子的重點是:

  • 當引數是 True 時,if x: 會成立,a = 1 會被執行,故 print(a) 會印出 1
  • 而當引數是 False 時,if x: 不成立,a = 1 不會被執行——但是!區域變數 a 仍然會被視為已經存在,只是未賦值而已。

執行結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
UnboundLocalError                         Traceback (most recent call last)
<ipython-input-20-30399ac74ec7> in <module>
5 print(a)
6 func(True)
----> 7 func([])

<ipython-input-20-30399ac74ec7> in func(x)
3 if x:
4 a = 1
----> 5 print(a)
6 func(True)
7 func([])

UnboundLocalError: local variable 'a' referenced before assignment

不出所料,我們在執行 func([]) 時,得到了一個 UnboundLocalError

有了實例一加持,這裡的 func([]) 為何會出錯,相信不難理解——因為沒有先賦值

從我舉的例子與書中的範例可以看出,弄清楚 Python 變數的各種特性,如賦值、修改與參照的本質,真是為了學好 Python,所不得不下的功夫。

而本書在這方面,確實頗有助益。


書中的精彩之處還有很多,限於幅篇,無法一一列舉,從書的介紹頁面看來,至少還有以下這些重點:

  • Python 全域變數不是真正全域?
  • Python 物件沒有真正的私有屬性,資訊都不用封裝隱藏了嗎?
  • 鴨子型別是什麼?我實在不懂這跟鴨子到底有什麼關係?
  • Python 的 list[n:m] 切片為何要有頭無尾?
  • Python 的型別與類別是同義詞?

這些議題,就留給讀者們自行探索了。


全書總評

本文所舉的內容,畢竟只是書中的一部分(當然是重要部分),主要用意在於引發讀者閱讀此書的興趣。

若看了文章覺得心動,建議趕緊前往天瓏書局網站為自己入手一本。因為它很有可能會是你這輩子所買過,最值得的一本 Python 參考書。

最佳 Python 入門書

《Python 技術者們 - 練功!》是一部值得一看再看、對學習與實際開發都頗有助益的優秀作品。就像開頭所說言,它就是我心中最佳的 Python 入門書,沒有之一。

全書讀下來,沒什麼多餘的部分,完全無愧於原文書名「The Quick Python Book」,讓你在有限的幅篇內,快速了解 Python 學習與應用上的重點所在。

好了,溢美之詞就到此為止。最後讓我對本書,表達深深的敬意與感謝。


結語:再會了!新手村

我相信,在你讀完這本《Python 技術者們 - 練功!》並將其中的內容——尤其是「老手帶路」部分——融會貫通後,你就再也不屬於 Python 新手村的一份子了。

從此你可以昂首闊步,大方地略過那些面向 Python 新手的「all-round」型入門書——因為你已經不太容易從它們身上,再獲得自己所不知道的 Python 知識。

然而,要成為一個「忠實 Python 信徒」的試煉,卻遠遠還沒有結束。

什麼?你說你不想?🥺,確實,這種事無法輕易強求。但如果「Yes」正是你的答案,那麼寫出符合 Python 風格的 Clean Code——即 Pythonic——則無疑是我們下一階段所要努力的目標。

不得不承認,入門 Python 或許很容易,但要把它寫得簡潔而優雅,卻得經歷非常多的辛苦與反思,堪稱一生的追求

最好的路,往往只有一條

Python 的高度自由,容易讓人誤以為:好像怎麼寫都可以?

然而事實正好相反,如〈The Zen of Python〉所言:

There should be one– and preferably only one –obvious way to do it.

Python 的自由,很可能會成為你通往 Clean Code 的阻礙。上述這段話的核心精神,也是 Python 使用者容易忽略的部分,以致程式碼過於隨意,不利團隊協作

如果你是有經驗的 Python 開發者,難免心有戚戚焉,這也是為什麼我們要特別學習,屬於 Python 的 Clean Code 風格。

這個議題,正是下一本書——《Python 功力提升的樂趣》——所要探討的。