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

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

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

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

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

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

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

系列:Python 書單推薦——Python 入門三部曲

  1. 《精通 Python 第二版》心得:給入門者的 Python 學習藍圖
  2. 最佳 Python 入門書——《Python 技術者們 - 練功!》心得與導讀
  3. 《Python 功力提升的樂趣》給 Python 開發者的 Clean Code 入門(預計 2024/02/25 發表)

本文目錄

  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 程式》則屬毫無疑問的傑作——用詞淺白,卻能直指痛處

除了翻譯的品質明顯比《精通 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,或是下面的相關文章。

相關文章:《Python 功力提升的樂趣》筆記(二)Pythonic、行話、陷阱


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

參見本書:頁 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 功力提升的樂趣》——所要探討的。