為你自己學 Python
給新手的 Python 線上程式課程
這是給想要學習 Python 的新手所寫的線上課程。 文字版教材:https://pythonbook.cc/ 本課程以 Python 3 做為主要教學語言,第一單位(PY101)內容涵蓋 Python 環境安裝、程式語言基礎語法,包括各種常用資料型態介紹、邏輯及流程控制、函數、物件導向程式設計等,並透過網站爬蟲程式抓取並分析資料。
課程大綱
19 章節 · 230 單元 · 25.5 小時安裝 Python 程式語言。Mac 系統通常已內建 Python,但版本較舊,建議另外安裝新版。Python 2 和 3 不相容,現在普遍使用 Python 3。安裝過程很簡單,下載後一路按下一步即可完成。Mac 用戶可透過終端機輸入 `python3` 來執行,Windows 用戶則可用 PowerShell 輸入 `python`。若要離開 Python 環境,可輸入 `exit()` 或按 Ctrl+D。整體來說,Python 的安裝比其他程式語言簡單許多。
REPL 是 Read、Evaluate、Print、Loop 四個字的縮寫。在這個環境中,系統會讀取你輸入的內容,評估執行後印出結果,然後等待下一個指令。例如在終端機輸入 python3 進入 REPL 環境後,輸入 1 就會印出 1,輸入 1 加 2 就會計算並印出 3。REPL 環境的提示符號是三個箭頭,輸入指令時不需要跟著打這三個箭頭。這個環境很適合用來執行簡單的程式或做測試,但不適合撰寫較長的程式碼。
pyenv 這個 Python 版本管理工具。Python 有很多版本和分支,像是 Jython、IronPython、PyPy 等,光是 3 系列就有 3.10、3.11、3.12 等多個版本。pyenv 本身不是 Python,而是一個工具箱,可以在同一台電腦安裝並管理多個 Python 版本。Mac 用戶需先安裝 Homebrew,再用 brew install pyenv 安裝;Windows 用戶則透過 PowerShell 安裝。安裝後可用 pyenv install 安裝指定版本,用 pyenv shell 或 pyenv global 切換版本。設定 global 後,每次開啟終端機都會使用指定版本,且可直接打 python 而不用打 python3。
只要有文字編輯器就能開始寫 Python 程式,不需要很厲害的工具。市面上有 PyCharm 這類功能完整的開發工具,雖然有免費的社群版本,但它是商業公司開發的,啟動速度稍慢。在這個課程中,我會使用 VS Code 這個文字編輯器,它簡單好用,在 Mac 和 Windows 都有對應版本,下載安裝後就能開始寫程式了。
示範如何用 Python 印出第一行程式。首先介紹用 `python -c` 指令直接執行一行程式碼,以及進入 REPL 環境互動執行,但這些方式不實用。接著說明正式開發流程:用 VS Code 開啟資料夾,建立 `.py` 檔案撰寫程式碼。推薦安裝 Python 和 Black Formatter 這兩個擴充套件,前者提供語法提示,後者自動排版。VS Code 內建終端機很方便,不用額外切換視窗就能執行腳本。雖然也能用 Google Colab 或 Jupyter Notebook,但建議在本機開發,把技能學進腦袋,不要被特定工具綁架。
註解是用井字號開頭的說明文字,目的是解釋程式碼的用途,例如說明 9.8 代表重力加速度。適當加註解能幫助同事或未來的自己理解程式碼的意圖。註解不會被程式執行,所以也常用來暫時停用某段程式碼,方便測試不同情況。在 VS Code 中,可用快捷鍵 Ctrl 或 Command 加斜線快速切換註解,還能一次選取多行批次處理,非常實用。學會這些小技巧能讓開發過程更順暢,也展現你對工具的熟練度。
檔案副檔名其實只是用來標記的,告訴作業系統這是什麼類型的檔案。Python 程式不一定要存成 .py,改成 .js、.php 甚至 .jpg 都能正常執行,因為本質上它就是個文字檔,Python 直譯器只在乎檔案內容,不在乎副檔名叫什麼。副檔名的主要用途是讓作業系統知道雙擊時該用什麼程式開啟,像 .doc 會開 Word、.xls 會開 Excel。不過實務上還是建議用 .py,因為 VS Code 等編輯器會根據副檔名提供正確的語法高亮顯示,改成其他副檔名反而會造成困擾。
在 Python 的 REPL 環境中輸入 import this,會顯示一段由核心開發者 Tim Peters 所寫的文字,稱為「Python 之禪」(Zen of Python)。這段文字列出了撰寫 Python 程式碼的原則,包括:優美優於醜陋、可讀性很重要、簡單優於複雜等。這些原則是寫 Python 時應該放在心裡的指引,遵循這些方向就能寫出更優美、簡潔、明瞭的程式碼。雖然初學階段可能還感受不到這些原則的好處,但寫個半年、一年之後,就會慢慢體會為什麼大家這麼喜歡 Python 這個程式語言。
Python 本身功能有限,大多需要依賴第三方套件。現在透過 PyPI 網站可以找到各種套件,類似 JavaScript 的 NPM 或 Ruby 的 RubyGems。安裝套件只需執行 pip install 加上套件名稱即可,pip 會自動處理相依套件的下載與安裝,比早期手動下載解壓縮方便許多。用 pip list 可查看已安裝的套件。不過 pip 有個問題:uninstall 時只會移除指定套件,不會連帶移除相依套件,導致殘留檔案。這雖然造成空間浪費,但更大的問題與接下來要談的虛擬環境有關。
pip 安裝套件時,同一環境只能存在一個版本,安裝新版會自動移除舊版,造成不同專案間的版本衝突。venv 是 Python 3.3 後內建的模組,可為每個專案建立獨立環境,透過 `python -m venv 環境名稱` 建立,再用 `source` 啟動。啟動後安裝的套件只存在該環境中,不影響其他專案。Mac 與 Windows 的啟動指令略有不同。另外說明 pyenv 管理 Python 版本,venv 管理套件版本,兩者層級不同。最後介紹 `pip freeze` 可輸出套件清單至 requirements.txt,方便部署時用 `pip install -r` 批次安裝。
venv 的缺點是移除套件時,相依的延伸套件不會一併移除,雖然只是浪費空間,但用起來不太舒服。Poetry 的角色類似前端的 NPM,功能更完整。安裝時建議用 pipx 來安裝,讓 Poetry 成為全域工具,不會跟虛擬環境混在一起。使用 poetry new 或 poetry init 可以建立專案,會產生 pyproject.toml 設定檔。透過 poetry add 安裝套件、poetry remove 移除套件時,相依套件會一併處理。Poetry 還會產生 lock 檔確保版本一致,也支援用 --group 參數將開發用套件分組管理。
Poetry 預設會把虛擬環境放在系統目錄,不像 venv 放在專案資料夾內,想查看位置可用 `poetry env info` 指令。我習慣修改設定讓虛擬環境建在專案內,方便管理和刪除,只要執行 `poetry config virtualenvs.in-project true` 即可。另外說明語意化版本的概念:主版號代表大改版、次版號代表新功能、修訂版號代表小修正。pyproject.toml 中的版本符號也很重要,波浪符號只升級修訂版,插入符號會升級次版號,等於等於則鎖定版本。建議不要太保守,以免錯過安全性更新。
介紹 Python 變數的基本概念與使用方式。變數就像標籤或名牌,用來標記電腦記憶體中的資料,方便後續存取。在 Python 中宣告變數非常簡單,不需要像其他程式語言那樣先宣告型別,直接寫 `a = 1450` 即可。示範如何使用 `print()` 印出變數值,並說明變數名稱與字串的差異。Python 支援多重指定,可一次宣告多個變數如 `x, y, z = 1, 2, 3`,若有不需要的值可用底線 `_` 代替。變數的值可隨時更改,但存取不存在的變數會出現 `not defined` 錯誤。最後提到 Python 沒有常數設計,慣例上用全大寫命名表示不應修改的值。
介紹 Python 變數命名規則。變數名稱可以使用文字、底線開頭,支援中文、日文等多種語言,但實務上建議用英文。數字可以出現在變數名稱中,但不能放在開頭,否則會產生 syntax error。大小寫視為不同變數,例如 a 和 A 是兩個不同的變數。Python 有 35 個保留字如 if、else 等,這些是語法專用,不能拿來當變數名稱。可以用 help() 函數查詢關鍵字或語法說明。另外還有「軟關鍵字」的概念,像底線、case、match、type 這四個,平常可以當變數使用,但在特定語法情境下會變成關鍵字。
介紹程式設計中常見的變數命名慣例。首先是駝峰式命名法(Camel Case),開頭小寫,單字間用大寫字母區隔,看起來像駱駝的駝峰。還有 Pascal 命名法,連開頭都大寫。接著是蛇式命名法(Snake Case),全部小寫並用底線分隔,這是 Python 和 Ruby 常用的方式。最後是烤肉串式命名法(Kebab Case),用橫槓分隔,但在 Python 等多數程式語言中是不合法的語法。雖然 Python 用駝峰式也能執行,但入境隨俗,建議還是採用蛇式命名法,這是 Python 社群的慣例。
介紹程式碼可讀性的重要性,特別是變數命名。程式碼寫一次但要讀很多次,不管是自己日後回顧或同事閱讀,好的命名能讓人一眼看懂意義。例如 `a = 18` 讓人摸不著頭緒,但 `age = 18` 就能立刻理解是年齡。電腦不在乎變數名稱,但工程師需要。電腦科學有兩大難題:cache 和 naming things,甚至有人專門出書探討命名。cache 的難處在於不知道何時該清除暫存更新內容,就像人生不知何時該放手。最後提到 Python 建議使用蛇式命名法,以底線分隔單字。
介紹 Python 變數交換的兩種方法。首先用碗和球的比喻說明,若要交換兩個變數的值,一次只能操作一個,因此需要第三個暫存變數。示範先將 Goku 存入 temp,再把 Ginyu 指定給 Goku,最後將 temp 指定給 Ginyu,完成三角輪調。這是一般程式語言的標準做法。接著介紹 Python 特有的簡潔寫法,利用多重指定的特性,一行程式碼就能完成交換,不需要額外的暫存變數。這種寫法其實是利用 Tuple 的特性實現,後續章節會詳細說明。
介紹 Python 的 `del` 語法,用於刪除變數。實際上在 Python 程式設計中很少需要手動刪除變數,因為程式執行完畢後,變數會自動歸還給系統。但如果想手動刪除,可以使用 `del` 關鍵字。示範建立一個變數 sister 並印出內容,接著使用 `del` 刪除該變數後,再次印出就會出現 not defined 錯誤,表示變數已被移除,或者說標籤已被撕掉。雖然 Python 提供這個語法,但實務上不太需要主動刪除變數,Python 的垃圾回收機制會自動處理這些事情。
介紹 Python 的 input 函數,讓程式能與使用者互動。首先用 print 印出問題詢問使用者年齡,接著透過 input 這個內建函數取得使用者輸入的內容,並將結果存入變數中。當程式執行到 input 時會暫停等待輸入,使用者按下 Enter 後,輸入的資料就會存進變數,再透過 print 把結果印出來。這個技巧可以應用在許多互動程式上,例如猜拳、剪刀石頭布或猜數字遊戲。對於初學者來說,能讓程式與電腦產生互動是相當有趣的體驗。
介紹 Python 的型別註記(Type Annotation)功能。Python 原本宣告變數不需指定型別,變數可以隨意改變內容,這種設計比較自由。但從 Python 3.5 開始提供型別註記,寫法是在變數名稱後加冒號和型別,例如 `age: int = 18`。這樣做的好處是讓 VS Code 或 PyCharm 等開發工具能提供更精確的方法提示。不過要注意,Python 的型別註記只是「僅供參考」,並沒有強制力,就算宣告是整數卻給文字,程式也不會報錯。這個功能主要是寫給隊友或開發工具看的,如果需要嚴格檢查,可以使用 mypy 等工具。至於該不該用,就看團隊習慣。
Python 把數字分成兩種型別:整數(integer)和浮點數(float)。對人類來說,123 和 123.0 意思相同,但電腦分得很清楚,只要帶小數點就是浮點數。使用內建函數 `type()` 可以查看資料型別,例如 `type(123)` 會顯示 int,而 `type(123.0)` 則顯示 float。這兩種型別在電腦世界裡是完全不同的東西,這點跟我們日常生活的認知不太一樣。下個主題會介紹文字,也就是字串。
Python 的四則運算使用 `+`、`-`、`*`、`/` 符號,其中除法會自動產生浮點數結果,即使運算元都是整數。若需要整數除法,可用雙斜線 `//`,餘數會被捨棄。運算式中只要出現浮點數,結果就會變成浮點數。Python 遵循先乘除後加減的數學規則,可用小括號改變優先順序。與 JavaScript 不同,Python 不允許數字與字串直接相加,這種嚴格的型別檢查其實是好事。另外,`**` 代表次方運算,`%` 則用來取餘數,常用於判斷整除關係。
Python 內建的 round() 函數可做四捨五入,第二個參數可指定小數位數。不過 Python 3 採用的是「銀行家捨入法」,遵循 IEEE 754 標準。當數字剛好落在中間值(如 0.5)時,會捨入到最接近的偶數,而非傳統的無條件進位。例如 round(0.5) 結果是 0,round(1.5) 則是 2。這樣設計是為了解決傳統四捨五入的偏差問題,因為 1-4 捨去、5-9 進位的分配並不公平。另外要注意,浮點數在電腦中無法精確表示,像 2.675 取到小數第二位會得到 2.67 而非 2.68,因為實際儲存的值略小於 2.675。
Python 的 math 模組提供 ceil 和 floor 函數,分別用於無條件進位和無條件捨去。ceil 是天花板,代表往上取整;floor 是地板,代表往下取整。Python 官方認為這些函數使用頻率較低,因此放在 math 模組中,需要時再匯入。整數的最大位數預設為 4300 位,超過會報錯,但可調整上限。整數和浮點數之間可用 int 和 float 函數互相轉換,int 轉換時會無條件捨去小數部分。這兩個函數也能將字串轉換成數字,是常用的型別轉換工具。
在程式設計中,單一等號不是數學上的「等於」,而是「指定」(assign)的意思。例如 `a = 1` 表示把 1 指定給變數 a。所以 `A = A + 1` 這個看似矛盾的式子,其實是把 A 目前的值加 1 後,再指定回 A,達成遞增效果。這種寫法可以簡化成 `A += 1`,把加號移到等號前面。同樣道理,`-=`、`*=`、`/=` 分別代表減、乘、除後再指定回來。Python 沒有其他語言的 `++` 或 `--` 寫法,我覺得這樣反而更清楚。記住:一個等號是指定,不是比較。
科學記號表示法用來簡化極大或極小的數字,例如 6.02 乘以 10 的 23 次方,省略部分細節只保留重要位數。電腦內部只認得 0 和 1,採用二進位運算。十進位轉二進位時,整數部分通常沒問題,但小數部分常無法整除,會產生無限位數。例如 7.625 能完美轉換,但 7.626 就會變成無限循環的二進位數。由於電腦無法儲存無限位數,浮點數必須捨去部分資訊,這就是浮點數不精準的原因。經典案例是 0.1 加 0.2 不等於 0.3,這在 Python、JavaScript、Ruby 都一樣,因為各語言都遵循 IEEE 754 規範,並非程式語言的問題。
Python 內建的 `bin()` 函數可將整數轉換為二進位表示,例如 `bin(100)` 得到 `0b1100100`,`bin(7)` 得到 `0b111`。反過來,`int()` 函數加上第二個參數 `2`,可將二進位字串轉回整數,如 `int('111', 2)` 得到 `7`。另外,二進位還有位元移位運算:向右移位 `>>` 會讓數值減半(如 `25 >> 1` 得到 `12`),向左移位 `<<` 則讓數值加倍。這是因為二進位每多一位就是乘以 2,少一位就是除以 2。這些概念平常寫網站或爬蟲較少用到,但了解原理還是有幫助的。
NaN 是 IEEE 754 規範中的特殊浮點數,代表「不是數字」(Not a Number)。它本身是浮點數,但用來表示無法計算的結果。用 `float('nan')` 可以建立 NaN,透過 `type()` 檢查會顯示它是 float 型別。NaN 像黑洞一樣,任何加減乘除運算結果都還是 NaN。不過它仍遵守某些數學規則:任何數的零次方是 1,1 的任何次方是 1。NaN 最特別的地方是它不等於任何東西,包括自己,所以無法用等號判斷。要檢查某值是否為 NaN,必須使用 `math.isnan()` 函數。
Python 的浮點數有個特殊值叫無限大(inf),這是 IEEE 754 規範定義的概念,不是具體數值。當浮點數超過 `sys.float_info` 的最大值(約 10 的 308 次方)時,就會變成無限大,不會像整數超過 4300 位數那樣報錯。可以用 `float('inf')` 或 `float('-inf')` 建立正負無限大。無限大的運算遵循數學邏輯:任何數加減乘無限大還是無限大,正無限大加負無限大會得到 NaN,因為這無法計算。無限大也遵守數學規則,例如零次方等於 1。可用 `math.isinf()` 判斷是否為無限大。
字串是程式語言中常見的資料型態,在 Python 裡可用單引號或雙引號包起來,兩者效果相同,官方建議選一種風格一致使用即可。當字串內容包含引號時,可用另一種引號包覆,或使用反斜線跳脫字元。反斜線有特殊用途:`\"` 或 `\'` 表示普通引號、`\n` 表示換行、`\t` 表示 Tab 縮排。若需要在字串中顯示反斜線本身,則用 `\\` 來跳脫。這些跳脫字元讓我們能在字串中表達各種特殊字元。
Python 字串必須寫在同一行,換行會造成語法錯誤。若需要多行文字,可用三個連續的單引號或雙引號包起來,中間就能自由換行,常用於撰寫文件內容。有些教學資料會說這是「多行註解」,但這是錯誤的觀念。Python 並沒有多行註解,只有單行註解,就是井字號。三引號包起來的內容本質上是多行文字,只是沒有指派給變數,所以執行後沒有效果。Python 編譯時甚至會直接丟掉這些沒被使用的多行文字,根本不會編進程式碼裡。
Python 提供 `str()`、`int()`、`float()` 等函數做型別轉換,例如 `str(18)` 會把數字轉成字串。但這些函數名稱不是保留字,可以被當作變數名稱使用。如果你寫了 `str = "hello"`,原本的 `str()` 函數就會被覆蓋掉,之後再呼叫 `str(123)` 就會出錯。同樣的情況也發生在 `print`、`list`、`int` 等內建函數上。雖然語法上允許這樣命名,但一旦覆蓋就無法在該範圍內使用原本的功能。這個設計確實不太理想,但 Python 就是這樣,所以變數命名時要特別小心,避免使用這些內建函數的名稱。
Python 中數字和字串是不同型態,不能直接相加,但字串可以乘以數字來達到重複效果,例如 "A" * 3 會得到 "AAA"。字串之間可以用加號串接,甚至省略加號也能自動合併,但這種設計容易造成程式碼難以閱讀,尤其在串列中忘記加逗號時不會報錯卻產生非預期結果。若要將數字與字串組合輸出,必須先用 str() 函數轉換型態,不過這種寫法較繁瑣。更好的做法是使用 %s 格式化或 format() 方法,後者透過大括號挖洞、再填入變數的方式,寫起來更直覺易讀。
Python 3.6 之後推出的 f 字串是格式化字串的簡便寫法,只要在字串前加上 f,就能用大括號直接嵌入變數。f 字串還有許多實用功能:用冒號加逗點可自動在千位數加上分隔符號;用 .2f 可指定小數位數;用百分比符號會自動乘以 100 並加上 % 符號。此外,f 字串支援欄位寬度設定,可指定靠左、靠右或置中對齊,也能自訂填充字元。最實用的是補零功能,例如 08d 會自動將數字補足 8 位數,處理時間格式時特別方便。這些功能在其他語言可能需要另外寫函數,但 f 字串一行就能搞定。
索引用來從字串或串列中取得特定位置的元素,使用中括號加上索引值來存取。Python 的索引從 0 開始計算,所以 message[0] 會拿到第一個字元,message[1] 拿到第二個。若要取得最後一個元素,可以用負數索引,-1 代表最後一個,-2 代表倒數第二個,這樣不管字串多長都能輕鬆取得尾端資料。如果索引值超出範圍,Python 會直接拋出 index error,而不是回傳空值或 undefined。我很喜歡這種設計,有問題就明確告知,而不是默默給一個模糊的答案讓你自己判斷。
Python 的字串是不可變的(immutable),無法透過索引直接修改其中的某個字元。嘗試用 `message[0] = "t"` 這種方式換掉字元會直接報錯。相較之下,JavaScript 的字串同樣不可變,但執行相同操作時不會報錯,只是靜靜地不生效,這種設計容易讓人誤以為修改成功。Python 的設計哲學是:既然不允許,就明確告訴你錯誤,而非讓你以為操作成功卻沒有實際效果。如果需要修改字串內容,只能整個字串重新賦值,無法單獨替換中間的字元。
索引值從 0 開始算是有原因的。字串或串列在記憶體中是連續存放的,每個格子有固定寬度。要取得某個元素,公式是「起始位置 + 索引值 × 寬度」。第一個元素不需要移動就能取得,所以索引值是 0;第二個元素要移動一格,索引值就是 1,依此類推。索引值其實就是「偏移值」(offset),代表相對於起始位置移動了幾格。不過並非所有程式語言都從 0 開始,像 Fortran、R 語言、Excel 都是從 1 開始,因為數學統計習慣如此。但 Python、Ruby、PHP 這些受 C 語言影響的程式語言,都習慣從 0 開始計算。
切片(slice)是從字串或串列中取出一段資料的方法,語法是 `[起始:停止:步長]`,三個位置用冒號分開。起始是開始的索引位置,停止是結束位置但不包含該位置的元素,步長是每次移動的距離。例如 `[0:2:1]` 從索引 0 開始取到索引 2 之前,每次走一格。`[2:8:2]` 則是從索引 2 到 8,每次跳兩格。步長可以是負數,表示反向取值,例如 `[8:3:-1]` 是從索引 8 往回走到索引 3。切片在 Python 中非常常用,字串和串列都適用這個語法。
Python 切片語法可以省略部分欄位來簡化寫法。兩欄式寫法中,省略開始位置預設為 0,省略結束位置則取字串長度,所以 `[:]` 可以複製整個字串。三欄式寫法較複雜,省略的欄位會根據 step 的正負號決定是左邊界值或右邊界值,這與兩欄式的預設值不同。例如 step 為負數時,省略開始位置會從右邊界開始,省略結束位置會到左邊界,這就是為什麼 `[:5:-1]` 和 `[None:5:-1]` 結果不同。切片只是複製該段內容,不會修改原字串。為了提高可讀性,可以用 `slice()` 函數建立切片物件並給予有意義的名稱。
Python 字串提供許多實用方法,像 upper、lower 可轉換大小寫,capitalize 讓首字大寫,swapcase 則交換大小寫。要注意這些方法不會改變原字串,因為 Python 字串是不可修改的,只會回傳新字串。比對方法包括 startswith、endswith 判斷開頭結尾,以及 isupper、islower 判斷大小寫。搜尋方面,index 和 find 都能找出字元位置,差別在於找不到時 index 會報錯,find 則回傳 -1。由於 -1 是有效索引值,使用時要特別注意情境。replace 可取代字串內容,strip 能去除頭尾空白。split 用指定符號拆解字串成串列,join 則反過來組合。這些方法在處理爬蟲資料時特別實用。
位元組(byte)與編碼是程式設計中重要的基礎概念。早期的 ASCII 編碼只用 7 個位元,僅能表示 128 個字元,對英文來說夠用,但無法涵蓋中文、日文等其他語言。各國因此發展出自己的編碼方式,如日本的 Shift_JIS、台灣的 Big5,但這導致網頁常出現亂碼問題。為解決這個困境,Unicode(萬國碼)誕生了,從最初的 16 位元擴充到現在的 20 位元,能容納全世界各種語言和 emoji。UTF-8 則是 Unicode 最常見的編碼實作方式,現在大多數瀏覽器和網站都預設使用它。
位元組(byte)是 Python 3 新增的內建資料型態,在字串前加上 `b` 即可建立。雖然位元組看起來像字串,但本質不同:用索引取值會得到 ASCII 編碼數字,而非字元本身。中文字無法直接放入位元組,需透過 `encode()` 編碼、`decode()` 解碼,預設使用 UTF-8。重要觀念是:我們存入電腦的並非字串,而是位元組。編輯器會自動幫我們編解碼,所以才能看到文字。若編解碼方式不一致,就會出現亂碼。Python 2 的 byte 等同字串,但 Python 3 兩者完全不同,這也是兩版本不相容的原因之一。
布林值是 Python 中的真假值,只有 True 和 False 兩種結果,注意開頭字母必須大寫,小寫會出錯。布林值的型態是 bool,是 boolean 的縮寫。比較特別的是,在 Python 中布林值其實是一種數字,True 等於 1,False 等於 0,所以可以進行數學運算,例如 True + True * 2 - False + True 會得到 4。雖然這樣設計有點奇怪,但知道這個特性即可,實際開發時不建議這樣寫。另外要注意 bool 不是保留字,如果拿來當變數名稱會覆蓋掉原本的功能,造成後續使用上的問題。
布林值可以做型別轉換。在 Python 裡,我們無法列出所有會轉成 True 的值,因為數量無限多,任何大於零的數字、任何字串都會轉成 True。但我們可以列出哪些是 False,其餘全是 True。會被轉成 False 的值包括:False 本身、None(代表沒有)、數字 0、空字串,以及空的容器如空串列、空 tuple、空字典。簡單來說,看起來「沒有」的東西都會是 False。要注意 Python 只有 0 是 False,負數如 -1、-2 都會轉成 True,這點跟某些程式語言不同。
布林值本質上是數字,可以用 `int()` 或 `float()` 轉換,`True` 會變成 1,`False` 變成 0。在 Python 中做數值比較會得到布林值,例如 `1 < 2` 回傳 `True`。一個等號是指定,兩個等號是比較,Python 不像 JavaScript 有三個等號的嚴格比較。有趣的是,Python 之父 Guido 早期設計 ABC 語言時,單一等號就是比較的意思,後來為避免混淆才改用兩個等號。因為 `True` 等於 1、`False` 等於 0,所以 `1 == True` 會成立,甚至可以用布林值當索引值取得字串元素,但這種寫法很怪,沒特殊目的不建議使用。
Python 的邏輯運算主要有三種:not、and、or。and 代表「而且」,兩邊都要成立結果才會是 True;or 代表「或」,只要其中一邊成立就會是 True;not 則是把結果反過來,True 變 False,False 變 True。這些運算結果可以用真值表(truth table)來查詢,但寫久了自然就會記住。另外要注意運算優先順序,在沒有括號的情況下,not 優先權最高,其次是 and,最低是 or,就像數學的先乘除後加減一樣,計算時需要特別留意。
Python 的邏輯短路是指在執行 and 或 or 運算時,如果前面的條件已經能決定最終結果,後面的運算就會直接跳過。以 and 來說,前面是 False 的話,後面不管怎麼樣結果都是 False,所以直接放棄運算。or 則相反,前面是 True 就不用看後面了。這個機制可以應用在效能最佳化上,把比較耗費 CPU 資源的運算放在後面,如果前面條件不成立,後面就不用白費力氣執行,節省運算資源。
Python 的位元運算使用單一 `&` 符號,與邏輯運算的 `and` 不同。位元運算會將數字轉換成二進位後逐位比較,例如 10(1010)與 12(1100)做 `&` 運算,因為 `&` 要求兩邊都為 1 才得 1,所以結果是 1000,也就是十進位的 8。布林值 `True` 和 `False` 本質上是 1 和 0,也能參與位元運算。`True & False` 得到 `False`,`True | False` 得到 `True`。要特別注意,Python 的邏輯運算用 `not`、`and`、`or`,而單一 `&` 或 `|` 是位元運算,兩者意義不同。
Python 中的流程控制使用 if 語法,例如判斷年齡是否大於等於 18 來決定是否印出訊息。條件後面要加冒號,小括號可省略。Python 的縮排是強制規定,不是慣例,因為沒有大括號來區分程式區塊,必須靠縮排判斷哪些程式碼屬於 if 內部。縮排不正確會直接報錯,無法執行。縮排可用空白或 Tab,數量不限,但同一區塊必須對齊。社群慣例是用四個空白。巢狀 if 就繼續往內縮排。這種強制規範能避免程式碼雜亂無章,我個人蠻喜歡這種設計。
Python 因為使用縮排來定義程式區塊,所以在 if 等結構中必須寫點東西,不能留空。註解不算程式碼,無法用來佔位。如果暫時不知道該寫什麼,可以用 pass 這個關鍵字來卡位,它本身不做任何事,純粹讓程式結構完整。雖然技術上放任何值如 1 或 2 也能通過,但看起來很奇怪且語意不明。不過如果經常需要用 pass,應該反思一下為什麼不知道要寫什麼,畢竟寫程式時應該清楚自己的目的。
Python 的條件判斷除了 if 之外,還有 else 和 elif。else 代表「不然」,當 if 條件不成立時執行,但 else 不是必要的,有 if 不一定要有 else,但有 else 一定要有 if。如果需要超過兩條路徑,可以用 elif(注意不是 else if,沒有空格也沒有 s 和 e)。例如判斷年齡:大於等於 20 歲可以選總統,大於 18 歲可以考駕照,其他情況走 else。在 if-elif-else 結構中,一定會走到其中一條路,elif 可以加很多個,想加幾個就加幾個。記得每個區塊都要縮排整齊。
閏年判斷是學習流程控制的經典練習題。判斷邏輯是:先看是否為 4 的倍數,不是就是平年;是的話再看是否為 100 的倍數,不是就是閏年;是的話再看是否為 400 的倍數,是就是閏年,不是就是平年。用取餘數運算子 `%` 判斷倍數關係,若 `year % 4 == 0` 表示是 4 的倍數。根據流程圖,用巢狀的 if-else 結構逐層判斷,搭配 `input()` 取得使用者輸入並用 `int()` 轉型。這段邏輯也能簡化成一行:4 的倍數且不是 100 的倍數,或是 400 的倍數,就是閏年。先求寫得出來,再求精簡。
剪刀石頭布遊戲的實作方式是這樣的:先用 input() 取得使用者輸入的 1、2、3 分別代表剪刀、石頭、布,接著用 int() 轉換型別。判斷輸入是否在有效範圍內後,透過 random 模組的 randint(1, 3) 讓電腦隨機出拳。比較邏輯是:雙方相同就平手,再用多個條件判斷誰贏誰輸。另外有個偷吃步的做法,因為電腦出什麼對使用者來說是黑箱,所以可以直接用隨機數決定輸贏結果,完全不需要真的比較,但這種做法在需要顯示雙方出拳內容時就不適用了。
Python 沒有 switch 語法,這是官方刻意的設計。在其他程式語言或資訊課程中,當 if else 很多時,教科書通常建議改用 switch 或 case 來讓結構更整齊。但 Python 作者認為 switch 沒那麼必要,直接寫一堆 if else 就好。事實上,很多程式語言的 switch 本質上就是一堆 if else 而已。雖然沒有 switch,但 Python 有個叫 match 的功能,比其他語言的 switch 更好用、更強大。至於為什麼沒有 switch,Python 社群討論後認為不需要,if else 寫法已經夠清楚了。
Python 有類似其他語言三元運算子的寫法,但語法不太一樣。JavaScript 用問號和冒號,Python 則是把 if 放在值的後面,例如 `message = "成年" if age >= 18 else "未成年"`。這種寫法剛開始看會不太習慣,雖然只要一行比較簡短,但簡短不代表容易懂。另外還有用 and/or 邏輯短路的寫法,或是把布林值當成 0 和 1 的索引來取值,這些寫法雖然合法也能得到正確答案,但我個人不建議使用,因為可讀性不好,我自己還是習慣用標準的 if else 來寫。
Python 3.10 新增了 match 語法,用起來類似其他語言的 switch,但功能更強大。原本處理多重條件判斷只能用一連串 if-else,現在可以用 match 搭配 case 來比對,程式碼看起來更有結構。match 背後是用雙等號做比對,最後一個 case 可以放任意變數名稱來接住其他情況,效果等同 else。如果不需要用到那個變數,可以用底線 `_` 表示不在乎。整體來說,match 讓多重條件判斷的可讀性稍微提升,但也不算是非常巨大的升級。
Python 的 match 語法不只是簡單的字串比對,它支援結構化模式比對(Pattern Matching)。首先,它可以比對型別,直接在 case 後面放型別如 int、float、str,省去使用 type() 檢查的步驟。更厲害的是結構比對,當資料結構符合指定模式時,會自動將對應位置的值指定給變數,不需額外宣告。不在意的值可用底線表示。這功能也適用於字典,可同時比對固定值和擷取變數。case 後面還能加 if 條件做進一步判斷,也支援用星號一次取得剩餘所有元素。這已經超越傳統 switch case 的功能了。
None 在 Python 中是一個特殊的值,用來表示「沒有」或「不存在」。這聽起來有點哲學,因為我們其實無法真正知道什麼東西不存在,一旦你知道它,它就已經存在了。就像 NaN 本身是個數字卻代表「不是數字」一樣,None 也是一個存在的值,用來代表不存在的概念。None 的首字母必須大寫,它的型別是 NoneType,自成一派,跟 True、False 的布林型態類似。使用方式很簡單,直接指定給變數即可,例如 girlfriend = None 就表示這個變數沒有值。其他語言可能用 Null 或 Undefined,但 Python 統一用 None 來表達這個概念。
Python 中比較運算有兩種方式:`==` 和 `is`。`==` 比較的是值是否相同,`is` 則比較是否為同一個物件。例如兩個內容都是 `[1, 2, 3]` 的串列,用 `==` 比較會得到 `True`,但用 `is` 比較會得到 `False`,因為它們是兩個獨立的物件,只是內容相同。就像網購買了兩箱一樣的東西,內容相同但就是兩箱。若寫成 `a = [1, 2, 3]` 再 `b = a`,這時 `a` 和 `b` 指向同一個物件,用 `is` 比較就會是 `True`。可用內建函式 `id()` 查看物件的記憶體位置,`is` 本質上就是比較兩者的 `id` 是否相同。
Python 中 `==` 比較的是值是否相同,`is` 比較的是否為同一個物件。兩個內容相同的字串用 `==` 比較會是 True,但用 `is` 比較結果可能出乎意料。Python 的字串是不可變的,為了效能考量,內部會建立一個表格來暫存常用字串,當你使用相同字串時,Python 會讓你共用同一個物件。不過這個機制有限制:只有較短且僅包含英文字母、數字、底線的字串才會被暫存,含有空格等其他字元就不會。數字也有類似機制,但只有 -5 到 256 之間的整數會被預先建立並共用,超過這個範圍就會產生新物件。這些設計都是為了提升執行效能。
判斷某個值是否為 None 時,雖然可以用 `==` 比較,但官方建議使用 `is`。原因有二:首先,Python 中 None 是唯一的單例物件,用 `is` 比對更精準。其次,`==` 運算子實際上會呼叫物件的 `__eq__` 魔術方法,而這個方法可以被覆寫。例如自定義類別若將 `__eq__` 改成永遠回傳 True,即使物件明明存在,用 `==` 與 None 比較仍會得到 True,造成判斷錯誤。因此為了避免這種潛在問題,建議一律使用 `is None` 或 `is not None` 來進行判斷。
迴圈是用來處理重複性工作的程式結構。當需要印出多次內容或處理不確定數量的資料時,手動複製貼上不切實際,這時就需要迴圈。Python 有兩種迴圈:for 迴圈和 while 迴圈。for 迴圈使用 `for ... in ...` 語法,會依序從串列中取出每個元素。例如 `for hero in heroes`,每次迭代都會把一個元素指派給變數。命名很重要,如果集合是複數名稱如 heroes,迴圈變數就用單數 hero,這樣程式碼可讀性較佳。若暫時不知道迴圈內要做什麼,可用 pass 佔位。
range 函數可以產生一個數值範圍,例如 `range(1, 11)` 會產生 1 到 10 的範圍,注意結束值 11 不包含在內。range 本身不是陣列,而是一種惰性的資料結構,在你真正需要之前不會展開成具體的串列,這樣可以節省記憶體。如果需要轉成串列,可以用 `list()` 函數轉換。這是 Python 3 的設計考量,Python 2 會直接展開成陣列,這也是兩個版本不相容的原因之一。如果只給一個參數如 `range(10)`,預設從 0 開始,產生 0 到 9 的範圍。
Python 的 for in 迴圈會進行「迭代」(Iteration),也就是一個一個把元素取出來的過程。Python 有很多可迭代物件,包括串列和字串。字串本身也是可迭代物件,例如把 "Hello Kitty" 放進 for in 迴圈,就會逐一取出 H、E、L、L、O 這些字元。for in 後面只要放可迭代物件就能運作,不限定只能用串列或字串,甚至自己定義的物件也可以做到,相當有彈性。
Python 的 for 迴圈有個需要注意的特性:迴圈變數可能會污染外層同名變數。例如外層已有 `number = "hello kitty"`,若 for 迴圈寫成 `for number in range(10)`,迴圈結束後外層的 `number` 會被覆蓋成 9。更特別的是,在迴圈內宣告的變數,離開迴圈後依然可以存取,這在其他語言中通常不允許。這種設計容易造成變數命名衝突,但透過函式和模組的封裝可以避免污染問題,等學到變數作用域(scope)時會更清楚如何處理。
在 for 迴圈中若需要索引值,可以使用 Python 內建的 enumerate() 函數。傳統做法是在迴圈外宣告計數器變數,每跑一圈就遞增,但這樣寫法較笨拙。enumerate() 能將串列轉換成帶有索引的格式,每個元素會變成 (索引, 值) 的組合。使用時可以在 for 迴圈中同時接收兩個變數,一個是索引,一個是元素值。預設索引從 0 開始,但可以透過第二個參數指定起始值,例如 enumerate(heroes, 1) 就會從 1 開始編號。這種寫法更符合 Pythonic 風格,程式碼也更簡潔易讀。
九九乘法表是練習雙層迴圈的經典題目。外層迴圈用 `for i in range(1, 10)` 產生 1 到 9,內層再用 `for j in range(1, 10)` 做第二層迭代。透過 f-string 格式化輸出 `f"{i} x {j} = {i*j}"`,其中乘號和等號只是裝飾用的字串,真正的運算是 `i*j`。工程師寫多層迴圈時,習慣用 i、j、k、l 依序命名變數,這是因為 i 可能代表 index 或 integer,而後續字母就順著往下取,算是一種不成文的慣例。
印出聖誕樹是經典的 Python 練習題。最簡單的半邊聖誕樹只要用 for 迴圈,讓星號乘以迴圈變數 i 就能印出 1、2、3、4、5 顆星的遞增效果。進階版要印出置中對齊的三角形,星星數量是 1、3、5、7、9,規律是「2 乘以 i 減 1」。前面要補空白讓圖形置中,空白數量則是「5 減 i」的遞減規律。寫迴圈最重要的是先找出規律,找到規律後程式碼自然就能寫出來。另一個更簡潔的做法是利用 f-string 的置中對齊功能,指定寬度後加上 ^ 符號就能自動置中,不用手動計算空白數量。
Python 的 for 迴圈可以搭配 else 使用,但這個 else 跟 if else 的「否則」不同。for else 的 else 區塊只有在迴圈「順利走完」時才會執行,所謂順利走完是指過程中沒有被 break 中斷。如果迴圈中途遇到 break 跳出,else 區塊就不會執行。這個語法在某些情況下很實用,例如檢查字串中是否存在特定字元。不過這個設計容易讓人困惑,因為 else 更像是「no break」的意思。Python 之父 Guido 也曾表示,如果能重來,他不會這樣設計。
while 迴圈用 while 關鍵字搭配條件判斷,只要條件成立就會持續執行。如果直接寫 `while True`,程式會無限循環,必須按 Ctrl+C 才能中斷。要避免無窮迴圈,必須在迴圈內加入控制機制來改變條件。例如設定 `i = 0`,條件為 `i < 10`,每次迴圈讓 `i += 1`,當 i 達到 10 時條件不成立,迴圈就會結束。實務上常見的寫法是設定一個狀態變數,在迴圈過程中根據某些條件改變它,讓迴圈能在適當時機停止。
猜數字遊戲是個經典的 while 迴圈練習。程式會用 random 模組的 randint 產生 1 到 100 的隨機整數作為答案,然後用 input 讓使用者輸入猜測。因為不知道何時會猜對,所以用 while 迴圈持續詢問,只要使用者的答案與正確答案不相等就繼續執行。每次猜測後會判斷數字太大或太小,給予提示讓玩家縮小範圍。要注意 input 回傳的是字串,必須用 int 轉型才能正確比較數值。用對半切的策略,因為 2 的 7 次方是 128,理論上 7 次內就能猜中。
迴圈裡有兩種流程控制:break 和 continue。break 會直接中斷整個迴圈,例如在 while True 的無限迴圈中,當條件符合時用 break 跳出。這種寫法等同於把終止條件放在 while 判斷式中,像猜數字遊戲就能用 break 在猜對時結束迴圈。continue 則是跳過本次迭代,直接進入下一輪,例如想印出偶數時,判斷如果是奇數就 continue,程式不會往下執行 print,而是回到迴圈開頭處理下一個數字。continue 比較像「下一位」的概念,不是繼續往下執行。這兩個關鍵字在 for 和 while 迴圈都適用。
for 迴圈和 while 迴圈都能重複執行任務,但使用情境不同。while 迴圈適合在不確定要執行幾次的情況下使用,概念是「跑到跑不動為止」,例如跑操場但不知道要跑幾圈。for 迴圈則適合在明確知道執行次數時使用,例如要印出 1 到 10,用 for 寫起來比 while 簡潔許多,不需要自己控制條件。簡單來說,條件明確時我傾向用 for 迴圈,條件不明確時則用 while 迴圈。不過這沒有絕對,只要熟悉兩者的用法,交換使用都沒問題。
串列(list)是 Python 中使用頻率很高的容器型資料形態,可以一次存放多個元素。想像一個藥盒,每格放不同天的藥,有順序性又能整包帶走,串列就是這樣的概念。寫法是用中括號括起來,元素之間用逗號分隔。雖然看起來很像其他程式語言的陣列,但串列不是陣列。Python 的串列不限制元素型態,數字、字串、甚至其他串列都能混著放,不像 Java 或 C 語言的陣列必須宣告固定型態。Python 其實另外有陣列,後面會再補充說明。
len() 函數可以計算序列的長度,不只適用於串列,也能用於字串、bytes、tuple、range 等序列型別。索引值從 0 開始,負數索引則從尾端往回數,例如 -1 取得最後一個元素。若索引超出範圍,Python 會拋出錯誤訊息。透過索引不僅能取值,還能直接賦值來替換元素。巢狀串列就像大腸包小腸,串列中可以再放串列,形成多層結構。要存取巢狀元素時,需依序使用多個中括號,例如 list[1][0] 取得第二層的第一個元素。若要遍歷巢狀串列,可用雙層迴圈逐一印出所有內容。
要判斷元素是否存在於串列中,最直覺的方式是使用 `in` 關鍵字,它會直接回傳 True 或 False,適用於多種資料結構。另一個方法是 `index()`,可取得元素的索引值,但若元素不存在會拋出 ValueError。還有 `count()` 方法能計算元素出現次數,若為 0 表示不存在。不過要注意,用 `index()` 做判斷有陷阱:當元素在索引 0 的位置時,因為 0 在布林判斷中等於 False,會導致判斷錯誤。因此單純判斷元素是否存在,建議優先使用 `in`,語法簡潔且結果明確。
使用 for 迴圈搭配複數命名的串列變數,可以清楚地逐一印出所有元素,while 迴圈雖然也能達成,但需要額外的計數器變數,寫起來較繁瑣。新增元素方面,append 方法可在串列尾端加入單一元素,extend 則能一次加入整個串列的內容。若要插入特定位置,insert 方法的第一個參數指定索引值,第二個參數是要插入的值,把索引設為 0 就能達到 prepend 的效果。移除元素可用 pop 方法,不帶參數會移除最後一個元素,帶索引值則移除指定位置的元素,並回傳被移除的值。remove 方法則是依據值來移除,但一次只移除第一個符合的元素,若要移除所有相同的值需搭配迴圈。當串列為空或找不到元素時,Python 會明確拋出錯誤。clear 方法可清空整個串列。
Python 串列的 sort() 方法會直接修改原本的陣列,如果不想改變原資料,可以改用內建函式 sorted(),它會回傳新的排序結果。兩者都支援 reverse=True 來做反向排序。透過 key 參數可以自訂排序依據,例如用 len 依字串長度排序,或傳入自訂函式計算排序權重。join() 方法則能將串列元素用指定符號串接成字串,比用 for 迴圈手動組裝更簡潔。查閱官方文件時,要注意參數型別,像 sorted() 和 join() 接受的是「可迭代物件」,代表不只能用在串列,字串等其他型別也適用。
可迭代物件是指能夠逐一取出內部元素的物件,像是字串、串列、元組、字典等都屬於這類。只要物件內部實作了特定方法,就能成為可迭代物件,甚至可以自己定義。Python 有許多內建函式能處理可迭代物件,例如 sum 可計算總和,但要注意不能混入非數字的元素。any 函式只要有一個元素為 True 就回傳 True,all 則要全部為 True 才成立。enumerate 能為可迭代物件加上索引值,但回傳的是惰性物件,需要用 list 展開才能看到內容。查閱文件時,只要參數標示 iterable,就能傳入任何符合規格的可迭代物件。
當 `B = A` 這樣寫時,A 和 B 會指向同一個串列,用 `is` 判斷會回傳 True。這意味著透過 A 修改內容時,B 也會跟著變動。若想讓兩個串列獨立,可以用 `A.copy()` 複製一份,這樣修改 A 就不會影響 B。但要注意 `copy()` 只做淺層複製,僅複製表層元素,若串列內還有巢狀結構,內層仍指向相同物件。若需要完整獨立的副本,要用 `copy` 模組的 `deepcopy()` 函式做深層複製。另一種方式是用 `list(A)` 建立新串列,效果類似淺層複製。
切片在陣列的操作方式跟字串幾乎一樣,都是用中括號加冒號來指定範圍。但最大的差異在於:字串是不可變的,只能讀取不能修改;陣列則可以直接改動元素。例如有個陣列 ABCDE,用 0:2 可以取出 A 和 B,而且我還能把這兩個位置換成 123,原本的 A、B 就會被取代。同樣地,用 3:6 取出 C、D、E 後,也能直接換成 45。這就是陣列切片最強大的地方,不只能把東西拿出來,還能直接替換成想要的內容,這是字串做不到的。
串列推導式(list comprehension)是 Python 中重要且常用的語法,用於資料轉換。它的運作方式是將一堆資料經過處理後,產生另一堆資料,無法無中生有。語法格式為中括號內包含 for 和 in,例如 `[n * 2 for n in range(5)]` 會產生 `[0, 2, 4, 6, 8]`。相較於傳統 for 迴圈需要先建立空串列再逐一 append,串列推導式能用一行程式碼達成同樣效果,更為簡潔。後面的可迭代物件可以是 range、串列等,前面則是對每個元素的處理結果,最終收集成新串列。
乘號可以用來重複串列元素,例如 `[0] * 3` 會得到三個 0。但這裡有個陷阱:用 `[[]] * 3` 產生的三個空串列其實指向同一個物件,修改其中一個,其他的也會跟著變。這是因為乘號做的不是真正的複製,而是讓多個位置指向相同的東西。如果需要獨立的空串列,應該用串列推導式 `[[] for _ in range(3)]` 來產生。同樣的問題也出現在巢狀串列相加時,例如 `a = [[1,2,3]]`,`a + a` 產生的兩個元素其實是同一個物件。這個坑在面試或特殊情況下可能會遇到,要特別留意。
串列推導式可以對元素進行操作,例如將字串中的每個字元轉成大寫後收集成新串列。基本語法是中括號內包含 for in 迴圈,前面放要收集的結果,後面接可迭代物件。進階用法可加入條件判斷,在 for in 後面加上 if 條件,只有符合條件的元素才會被收集。例如從 1 到 10 中篩選偶數,用「if number % 2 == 0」判斷能否被 2 整除。這種寫法跟數學集合表示法概念相似,結果在前、條件在後、迴圈在中間。一行程式碼就能完成原本需要 for 迴圈加 if 判斷再 append 的多行寫法。
串列推導式可以寫成多層結構,像是用來產生九九乘法表。雙層推導式的寫法是在一個 for 後面再接一個 for,雖然可行但讀起來不太舒適,如果再加第三層就更難閱讀了。推導式有個好處是變數不會汙染外部作用域,在推導式裡定義的變數,外面拿不到。相較之下,for 迴圈裡的變數在迴圈結束後還能從外面存取,這可能造成變數汙染的風險。不過要注意,這是 Python 3 的行為,Python 2 的推導式變數還是會外洩到外部。
串列開箱(unpacking)是 Python 實用的語法,可將可迭代物件中的元素直接指派給多個變數,省去逐一用索引取值的步驟。只要變數數量與元素數量對應,就能一次完成指派。若數量不符,Python 會明確報錯,不像 JavaScript 那樣給 undefined 或忽略。使用星號(`*`)可接收剩餘元素,例如取頭尾時中間用 `*_` 接住不需要的部分。Python 夠聰明,星號不必放最後也能正確分配。還能做巢狀開箱,從複雜資料結構中精準取值。此語法適用於所有可迭代物件,包含字串與 Tuple,是 Python 3 之後的特性。
序列開箱(unpacking)在 Python 中應用廣泛。變數交換 `x, y = y, x` 之所以能運作,是因為右邊其實是個 tuple,透過開箱機制將值分別賦予左邊的變數。同樣地,`for i, hero in enumerate(heroes)` 也是開箱操作,從每個 tuple 中取出索引與元素。星號(`*`)作為開箱運算子,除了收集剩餘元素外,還能展開序列。例如 `[*range(5)]` 可將 range 物件展開成串列,效果等同於 `list(range(5))`。這個技巧也能組合不同型態的可迭代物件,像是串列、字串、tuple 和 range,只要分別用星號展開再以中括號包起來即可。另外還有雙星號(`**`)專門用來開箱字典。
Python 的串列不是陣列,陣列是另一種資料型態,需要 import array 模組才能使用。使用陣列時必須先宣告型別,例如 i 代表整數、f 代表浮點數、u 代表 Unicode,宣告後只能放入相同型別的資料。這樣的設計是為了效能考量,因為固定型別讓記憶體配置更有效率,可以透過索引值快速取得元素。Python 的陣列與傳統陣列不同,雖然不能混放型別,但長度可以動態調整,支援 append 和 insert 等操作。雖然陣列效能較好,但串列使用更直覺方便,所以實務上大多還是使用串列。
字典是 Python 中常用的資料結構,由 key 和 value 配對組成,外層用大括號包覆。相較於串列,字典能為資料加上標籤,讓內容更容易理解,例如用 name、level、power 等 key 來標示對應的值。建立字典有兩種方式:使用大括號字面值,或使用 dict() 函式。效能上,大括號寫法較佳,因此若無特殊需求建議優先使用。dict 類別還提供 from_keys() 方法,可快速用可迭代物件建立字典,未指定值時預設為 None,也可傳入第二個引數設定所有 key 的預設值。
字典取值方式跟串列類似,但因為字典沒有順序,所以用 key 而非索引值來取值。如果 key 不存在會拋出 KeyError。為了避免錯誤,可以用 `in` 判斷 key 是否存在,或用 `get()` 方法取值,拿不到會回傳 None,也能設定預設值。另外還有 `setdefault()` 方法,當 key 不存在時會新增該 key 並設定值,若已存在則回傳原本的值。雖然 `setdefault()` 也能取值,但我個人偏好用 `get()`,因為語意更清楚直覺。
Python 3.6 之前,字典使用雜湊表實作,因為雜湊值無法預測大小順序,所以字典內容的排列順序是不確定的。但從 3.6 版本開始,字典會保留 key 的加入順序,先加入的會排在前面。不過這裡說的「有順序」只是指排列上有順序,實際存取時仍然無法用索引取值,還是得透過 key 來取得對應的值。如果需要真正支援索引的有序資料結構,可以使用串列或 Tuple。
字典的新增或更新操作很簡單,使用 key 加上等號即可。例如要把 name 從「地球人」改成「火星人」,直接指定 key 並賦值就完成了。讀取時若 key 不存在會出現 KeyError,但設定時若 key 不存在,Python 會自動新增這組 key-value,不會報錯。除了逐一更新,也可以用 update 方法一次更新多筆資料。update 會將新字典的內容合併到原字典,已存在的 key 會被覆蓋,不存在的則新增。不過 update 這名字有點誤導,因為它實際上比較像合併而非單純更新,使用前要確認這是你要的效果。
要判斷字典裡有沒有某個 key,可以用 `in` 關鍵字,簡單直覺。另外還有個 `__contains__` 方法也能達到相同效果,會回傳 True 或 False。這種雙底線開頭的方法在 Python 中很常見,因為英文是 double underscore,簡稱 dunder,所以叫 dunder method,又稱為魔術方法。不過這些魔術方法通常是給系統內部呼叫用的,例如當我們使用 `in` 關鍵字時,Python 內部其實就是去呼叫 `__contains__`。所以一般情況下,建議直接用 `in` 這種簡潔的寫法就好,除非你很清楚自己在做什麼,否則不需要直接呼叫魔術方法。
字典的 key 必須是不可變的資料型態,例如字串、數字、布林值和 tuple。布林值 True 等於 1,所以用 True 當 key 時,也能用 1 取值。數字可以當 key,雖然寫成 0、1、2 看起來像陣列,但本質仍是字典,沒有順序概念,不能用 -1 存取。tuple 因為不可變,也能當 key 使用,例如用座標 tuple 對應城市名稱。但如果用串列當 key 會出錯,因為串列是可變物件,Python 會回報 unhashable type 錯誤。簡單來說,能當 key 的東西必須是可被雜湊的不可變物件。
Python 的字典(Dictionary)命名其實有其道理。字典這個資料結構在其他語言有不同稱呼,像是雜湊(Hash)、關聯陣列,或 JavaScript 的物件。Python 選擇「字典」這個名稱,是因為它能直觀表達 key 與 value 的一對一對應關係,就像查字典時每個單字對應一個解釋。雖然 Python 字典底層是用雜湊表實作,但若直接叫 hash 容易與雜湊函式或雜湊值混淆。而 JavaScript 叫物件也有問題,因為陣列也是物件,容易造成混淆。相較之下,「字典」這個命名清楚表達了 key-value 的配對特性,是個相當好的設計選擇。
Python 字典可以用 `len()` 函式計算有幾組 key-value 配對。`clear()` 方法會清空整個字典內容,而 `del` 關鍵字可刪除指定的 key,但要小心別誤刪整個字典。取出並移除元素可用 `pop()` 方法,需指定 key,若 key 不存在會報錯,但可設定預設值避免錯誤。`popitem()` 方法則會取出最後一組 key-value 配對,Python 3.6 之後字典按照插入順序排列,採用 LIFO 原則,最後放入的最先被取出。透過 while 迴圈搭配 `popitem()` 可逐一取出所有元素,直到字典變空為止。
Python 字典合併有幾種方式。第一種是使用雙星號開箱運算子,將兩個字典展開後用大括號包起來,例如 `{**dict1, **dict2}`,這種寫法簡潔直覺。第二種是透過 `dict()` 函式,將一個字典作為參數,再用雙星號展開另一個字典作為關鍵字引數傳入。第三種是使用 OR 運算子 `|`,直接寫 `dict1 | dict2`,但這是 Python 3.9 之後才支援的語法。不論哪種方式,當兩個字典有重複的 key 時,後面的會覆蓋前面的,因此 a 合併 b 和 b 合併 a 的結果可能不同,使用時須留意順序。
當 A 等於某個字典,B 等於 A 時,兩個變數會指向同一個字典,改其中一個另一個也跟著改。為避免這情況,可以用 copy 方法複製一份新字典,讓兩者各自獨立。但 copy 只能複製第一層,遇到巢狀結構時,深層內容仍會互相影響。這時要用 copy 模組的 deepcopy 函式做深層複製。另外也可以把字典丟給 dict() 函式,或用兩個星號做開箱來複製,但這些同樣只能複製淺層。簡單的淺層複製我習慣用 copy 方法,深層複製則只能靠 deepcopy。
字典的 keys()、values() 和 items() 方法可以分別取出所有的鍵、值,以及鍵值配對。items() 會回傳 tuple 組合,搭配 for 迴圈和開箱語法很方便。這三個方法回傳的都不是串列,而是特殊的 view 物件,這是為了效能考量,避免大型字典一次載入佔用大量記憶體。view 物件是惰性的,需要時才取值。有趣的是,這個 view 像是一扇窗戶,當原始字典內容改變時,透過它看到的結果也會跟著變動,不需重新呼叫方法。另外,因為字典的 key 不會重複,dict_keys 物件可以做集合運算如交集聯集,而 dict_values 則沒有這個特性。
串列有串列推導式,字典也有字典推導式,兩者都用於資料轉換。字典推導式使用大括號,且因為字典由 key 和 value 組成,寫法中間需要加上冒號。例如用 `{x: x**2 for x in [1,2,3,4,5]}` 可以產生以數字為 key、平方為 value 的字典。若要將兩個串列合併成字典,可以使用內建的 `zip()` 函式,它像拉鍊一樣把兩個串列配對成一組一組的 tuple。配合字典推導式就能輕鬆轉換成字典。更簡潔的做法是直接把 `zip()` 的結果丟進 `dict()` 函式,連推導式都不用寫就能得到相同結果。
雜湊函式(hash function)能將任意長度的資料轉換成固定長度的字串,這個結果稱為雜湊值。雜湊函式的特點是輸入與輸出有固定對應關係,相同輸入必定產生相同輸出,但只要輸入差一個字,結果就完全不同,這叫做雪崩效應。因此雜湊常用來驗證檔案是否被竄改。雖然不同輸入可能產生相同結果(稱為碰撞),但機率極低。Python 的 hash() 函式可進行雜湊計算,但只有不可變物件才能被雜湊,串列、字典等可變物件無法計算。字典的 key 必須是可雜湊物件,若兩個物件的雜湊值相同,作為 key 時後者會覆蓋前者。
Tuple 是 Python 中常用的資料型態,發音可念 Tuple 或 Tuple,連 Python 之父 Guido 也說隨意。建立 Tuple 使用小括號,元素用逗號分隔,也可用 tuple() 函式轉換可迭代物件。Tuple 與串列最大差異在於不可變動,一旦建立就無法修改內容,特性上更接近字串。若 Tuple 只有單一元素,後面的逗號不可省略,否則會被當成一般數值。小括號在某些情況可省略,但逗號才是決定 Tuple 的關鍵。遇到運算式時,省略小括號可能造成語意混淆,建議保留以提高可讀性。
tuple 跟陣列一樣有順序,可用索引值取得元素,從 0 開始,也支援負數索引。但 tuple 是不可變的資料型態,無法修改裡面的元素。使用 += 看起來像修改,其實是產生新的 tuple 再重新指定,這叫 reassign。tuple 支援 for in、len、count、index 等操作,但 append、insert、remove 這些破壞性操作都不行。要注意的是,如果 tuple 裡面放了可變物件如陣列或字典,那個物件本身還是可以修改的,這種 tuple 就不是可雜湊物件,不能當字典的 key。選擇 tuple 主要不是效能考量,而是不可變性帶來的安全性,資料傳遞時不怕被意外修改。
tuple 跟串列一樣可以複製,但 tuple 沒有 copy 方法,只能用 tuple() 函式、copy 模組或切片來做。不過這裡有個重要觀念:tuple 的複製其實不是真的複製,而是指向同一個物件。用 is 判斷會發現複製前後是同一個東西。為什麼這樣設計?因為 tuple 是不可變的資料結構,既然內容不能改,複製一份只是浪費記憶體。但要小心,如果 tuple 裡面包含可變物件(如串列),改動該物件的內容,原本的 tuple 也會受影響,這點操作時務必注意。
建立空的 tuple 很簡單,直接用小括號 `()` 或呼叫 `tuple()` 函式即可。空的 tuple 有個有趣特性:不像一般 tuple,即使分別建立兩個空 tuple,用 `is` 比較會發現它們是同一個物件。因為空 tuple 無法新增或修改內容,Python 為了節省記憶體,讓所有空 tuple 都指向同一個物件。實務上很少用到空 tuple,可能的用途是當函式需要維持回傳型別一致性時,找不到資料就回傳空 tuple,而非 None 或 False。不過我個人不太堅持這點,回傳 None 通常就夠了。
tuple 跟串列相比,效能確實好一些。用 timeit 模組測試,同樣內容跑 1000 萬次,tuple 比串列快,但差距大約只有 0.3 秒,所以效能不會是選擇 tuple 的主要理由。記憶體使用上,tuple 也比串列省。原因在於串列是可變動的,Python 會預留額外的 capacity 當作 buffer,避免每次新增元素都要重新尋找更大的連續記憶體空間。tuple 因為不可變動,不需要預留空間,所以佔用的記憶體較少。整體來說,tuple 在效能和記憶體使用上都優於串列,但選擇 tuple 的理由通常不會只是效能考量。
Python 的小括號推導式語法看起來像 Tuple 推導式,但實際上會產生一個 generator(產生器),而非 Tuple。產生器可以用 for 迴圈遍歷,也能用 next() 函數逐一取值,取完後會拋出 StopIteration 錯誤。若需要真正的 Tuple,可將產生器轉型。至於串列和 Tuple 的選擇,我的建議是:確定內容不會變動就用 Tuple,會變動就用串列。效能差異在一般應用中影響不大。另外根據官方文件的風格建議,同質性資料(如全是數字)適合用串列,異質性資料(混合不同型別)則適合用 Tuple,但這只是慣例而非硬性規定。
集合(set)是 Python 內建的資料型態,有兩個重要特性:元素不會重複,且沒有順序。建立集合可用大括號或 set() 函式,放入可迭代物件即可。若有重複元素會自動過濾,但轉換後順序不保證與原本相同。集合常用於去除重複元素,例如將串列轉成集合再轉回來。要注意集合只能存放可雜湊物件,像串列這種可變動的資料就無法放入。有個有趣的冷知識:因為 NaN 不等於自己,所以集合中可以存在多個 NaN,看起來像是有重複元素,但這只是特例,實務上不會這麼用。
集合是一種可迭代物件,可以用 for 迴圈遍歷,但要注意集合沒有順序,每次迭代的順序可能不同。如果需要順序,應該使用 tuple 或串列。集合可以新增、修改、刪除元素:用 add 新增元素,用 remove 刪除元素,但刪除不存在的元素會產生 key error。為了安全刪除,可以先用 in 判斷元素是否存在,或使用 discard 方法,即使元素不存在也不會報錯。用 clear 可以清空集合。建立空集合不能用空的大括號 {},因為那會產生空字典,必須用 set() 來建立。複製集合可以用 copy 方法或 set() 函數,但因為沒有順序,無法使用切片複製。
集合除了不重複、無順序的特性外,還支援交集、聯集、差集等數學運算。交集使用 `&` 符號或 `intersection()` 方法,取得兩集合共同的元素。聯集使用 `|` 符號或 `union()` 方法,合併所有元素但不重複。差集使用 `-` 符號或 `difference()` 方法,從一個集合減去與另一個集合重疊的部分。交集和聯集沒有順序性,但差集有,因為 A 減 B 和 B 減 A 結果不同。此外,`isdisjoint()` 方法可判斷兩集合是否有交集,回傳布林值。
集合有子集合(subset)與超集合(superset)的概念。以 {1,2,3,4,5} 和 {2,3,4} 為例,後者的元素都包含在前者中,所以 {2,3,4} 是 {1,2,3,4,5} 的子集合,反過來則是超集合。Python 提供 is_subset() 和 is_superset() 方法來判斷這種關係。另外還有「嚴格子集」(proper subset)的概念,條件是元素必須都被包含,但兩個集合不能完全相等。例如 {1,2,3} 是 {1,2,3,4} 的嚴格子集,但 {1,2,3,4} 對自己就只是子集合而非嚴格子集。這些數學概念在實際寫程式時不常直接用到,可當作複習數學。
集合推導式的寫法是用大括號包住單一元素,例如 `{n for n in range(5)}`。要注意的是,如果大括號內放的是 key-value 組合,產生的會是字典而非集合,因為這兩種資料結構共用相同的符號。另外有個冷知識:如果集合裡不小心放了 `nan`,用 `remove()` 是拿不掉的,因為 `nan` 無法跟自己比較相等。解決方法是透過推導式過濾,利用 `item == item` 這個條件,因為只有 `nan` 不等於自己,就能把它濾掉。不過實務上很少會遇到這種情況。
Frozen Set(冷凍集合)是 Python 中一種特殊的集合型態。由於大括號、小括號、中括號都已被其他資料型態使用,建立冷凍集合只能透過 `frozenset()` 函式,傳入可迭代物件即可。冷凍集合與一般集合最大的差異在於它是不可變動的,就像 Tuple 一樣,無法使用 `add()`、`remove()`、`clear()` 等方法來新增或刪除元素。也因為不可變動的特性,冷凍集合是可雜湊物件,理論上可以當作字典的 Key 使用,雖然實務上這種情境相當少見。
函數是輸入值與輸出值之間的對應關係,就像國中數學的 f(x) = 3x + 2,帶入不同的 x 會得到對應的結果。寫函數的主要目的不是為了重複使用,而是把程式碼抽象化,賦予一段邏輯一個有意義的名稱。即使函數只用一次也值得寫,因為好的函數名稱能讓人一眼看懂這段程式碼想做什麼,不需要閱讀內部實作細節。重複使用只是函數的副產品,真正的價值在於讓程式碼更具可讀性。命名時要避開保留字和內建函數名稱如 list、str、int 等。
定義函數使用 `def` 關鍵字,後接函數名稱、小括號和冒號。函數內的程式碼必須縮排,這是 Python 的強制規定,用來標示程式碼區塊的範圍。不像其他語言用大括號區隔,Python 靠縮排來判斷哪些程式碼屬於同一區塊,沒縮排或縮排錯誤會直接報錯。執行函數時要在名稱後加小括號,否則不會執行。函數命名應該清楚表達用途,例如 `say_hello` 比 `hi` 更好理解。縮排不只是為了美觀,更是讓程式碼有結構、易閱讀的重要習慣。
Python 的函數或類別定義若內容為空會造成語法錯誤,因為 Python 強制要求縮排區塊必須有內容。註解不算程式碼,所以無法用來佔位。雖然放數字、字串或 None 都能通過語法檢查,但語意上不太恰當。Python 為此設計了 pass 這個關鍵字,它本身不執行任何動作,純粹用來卡位。當我們定義函數、類別或 if-else 區塊,但還不確定要寫什麼內容時,就可以用 pass 先佔住位置。不過也值得思考:如果連內容都還不知道,是否真的需要這麼急著把結構寫出來?
函數裡的 x 在程式中稱為「參數」(parameter),是定義函數時設定的變數名稱,用來接收傳入的值。參數命名要有意義,像 someone 比 ABC 更清楚。呼叫函數時帶入的值則稱為「引數」(argument),例如 say_hello("Kitty") 中的 Kitty 就是引數。Python 對參數數量要求嚴格,定義幾個就要帶幾個,多或少都會報錯,這點跟 JavaScript 不同。錯誤訊息會明確指出 missing argument 或 takes 0 but 1 was given,所以參數和引數雖常被混用,但其實是兩個不同概念。
呼叫函數和執行函數這兩個詞的立場不太一樣。呼叫函數是指去請求、叫這個函數動起來;執行函數則是指函數運算的過程。也就是說,需要先呼叫才會執行,這兩個動詞代表不同的階段。不過對工程師來說,很多時候會混著用,反正意思知道就好,就是要讓函數動起來、帶東西給它這樣。
Python 函數的參數設計可分為「位置引數」和「關鍵字引數」兩種。位置引數按順序傳入,一個蘿蔔一個坑,順序不能錯。關鍵字引數則用參數名稱指定值,例如 `height=100, weight=200`,好處是順序可以任意調換,不怕搞混。以計算 BMI 為例,若用位置引數,搞反身高體重會得到錯誤結果;改用關鍵字引數就能避免這問題。Python 內建的 `print()` 函數也用了關鍵字引數,像 `sep` 可改分隔符號、`end` 可改結尾字元。這兩種引數可以混用,讓函數設計更有彈性。
關鍵字引數使用時有幾個規則:位置引數必須放在關鍵字引數前面,否則會報錯。同一個參數不能帶兩次,不管是用位置引數還是關鍵字引數,重複帶值會出現錯誤訊息。若要限定參數的使用方式,可以用斜線和星號標記。斜線前面的參數只能用位置引數,星號後面的參數只能用關鍵字引數,中間的則不限制。以內建函數 sorted 為例,第一個參數只能用位置引數帶入,而 key 和 reverse 則必須用關鍵字引數。這樣的設計讓函數設計者能明確規範使用方式。
Python 函數可以設定參數預設值,在定義參數時用等號指定預設值,呼叫時若未傳入該參數就會使用預設值。設定預設值的主要目的不是偷懶,而是讓函數使用起來更有彈性,常用的值設為預設,使用者可以不帶參數直接使用,也可以傳入自訂值來客製化。像 print() 函數就有多個預設參數,讓基本使用變簡單,同時保留客製化空間。但要注意,有預設值的參數必須放在沒有預設值的參數後面,否則會造成語法錯誤,這是 Python 的設計規則。
Python 函數參數的預設值在定義時就已經決定,這是個常見的坑。如果預設值是 `random.random()` 這類函數呼叫,每次執行函數都會得到相同結果,因為該值在定義時就固定了。同樣地,若預設值是空串列等可變動物件,多次呼叫函數時會共用同一個物件,導致資料累積而非重新開始。解決方法是將預設值設為 `None`,進入函數後再判斷:若為 `None` 才建立新物件或呼叫函數。數字、字串等不可變物件作為預設值則沒有這個問題。
Python 的 print 函數可以接受任意數量的參數,這是透過星號(`*`)語法實現的。在函數參數前加上星號,表示該參數會收集所有傳入的位置引數,並打包成一個 tuple。例如定義 `def hi(*values)`,不論傳入一個、三個或十個引數都能處理。若函數同時有一般參數和星號參數,如 `def foo(a, *b)`,則 a 先取得第一個值,剩餘的全部由 b 收集。當星號參數後面還有其他參數時,如 `def foo(a, *b, c)`,由於星號會「吃掉」所有位置引數,後方的 c 就必須使用關鍵字引數的方式傳入,否則會產生錯誤。
Python 函數中,單星號(`*`)可以接收所有位置引數並收集成 tuple,但遇到關鍵字引數會出錯。要同時處理兩種引數,需搭配雙星號(`**`),它會把關鍵字引數收集成字典。常見寫法是 `*args` 和 `**kwargs`,這組合能接收任意數量的位置和關鍵字引數。args 是 arguments 的縮寫,kwargs 是 keyword arguments 的縮寫,但這只是慣例命名,不是 Python 內建的特殊字,你可以用任何喜歡的參數名稱。
Python 的星號有多種用途,其中一個是解包(開箱)功能。當函數定義了固定數量的參數時,可以用單星號將串列或 tuple 解開,自動對應到各個位置引數,省去逐一用索引取值的麻煩。但要注意元素數量必須與參數數量相符,否則會出錯。集合因為沒有順序,解包時順序可能不如預期。若要用關鍵字引數的方式傳入,可以用雙星號解開字典,此時字典的 key 必須與函數的參數名稱對應。單星號解開後是位置引數,雙星號解開後是關鍵字引數,兩者使用時都要確保數量和名稱正確匹配。
在函數內第一行放置的字串稱為 Docstring(文件字串),這不是註解,而是用來說明函數用途的文字。使用三個引號可以撰寫多行說明,單引號或雙引號皆可。透過 help() 函數可以查看任何函數的 Docstring 說明,例如 help(print) 會顯示 print 函數的使用方式。函數物件有個特殊屬性 `__doc__` 可以直接取得這段說明文字。Docstring 可放在模組、函數或類別中,目的是為程式碼加上文件說明,方便自己或他人理解如何使用。當不確定某個函數怎麼用時,除了 Google 或問 GPT,也可以用 help() 查看。
函數的回傳值是很重要的概念,很多新手容易把 print 和 return 搞混。print 只是在執行過程中把東西印在畫面上,但那不是函數的結果。當我們把參數丟給函數,應該要得到一個答案回來,這才是回傳值。在 Python 中,如果要讓函數有回傳值,必須明確寫出 return,否則函數執行完就是沒有結果,會得到 None。設計函數時,應該讓輸入值和輸出值之間的對應很明確,每個函數只要做好自己該做的事就好,例如 add 函數就專心算加法,把答案回傳就好,不需要負責印出來,印的工作交給 print 來做。
return 不只是回傳值,更是將程式控制權交還給呼叫者。當函數執行到 return 時會立即結束,後面的程式碼都不會執行。若 return 後面沒有接任何值,Python 會自動回傳 None。利用這個特性可以使用 early return 技巧:在函數開頭先檢查參數是否合法,不合法就直接 return 結束。這樣可以省略 else,讓程式碼結構更清楚。
Python 的函數即使沒有明確寫 return,也會回傳 None。所以「所有函數都有回傳值」這句話是對的,因為 None 本身就是一個實實在在存在的物件,只是用來代表「沒有」或「不存在」。這其實是個有趣的哲學問題:我們無法真正描述「不存在」,因為當你知道某個東西不存在時,就代表你已經知道它了。None 就像站在路邊舉著「這裡沒有人」牌子的人,它明明存在,卻用來表示不存在。不管是 None 還是 NaN,都是用一個存在的東西來描述「沒有值」這件事。
Python 中的函數是「一等公民」(First-class citizen),意思是函數跟數字、字串、串列等資料型態一樣,都是可以丟來丟去的值。既然 123 可以指定給變數,函數當然也可以;既然字串可以放進串列,函數也行。這帶出「高階函數」的概念:只要一個函數能接收其他函數當參數,或是能回傳函數,就稱為高階函數。其實沒什麼神秘的,就是把函數當成一般物件看待而已。正因為函數是物件,所以可以用 `__doc__` 取得文件字串,甚至能在函數物件上隨意新增屬性。不過並非所有程式語言都這樣設計,例如 Ruby 的函數就不是物件。
Python 變數有作用域(scope)的概念,決定變數在哪裡可以被存取。作用域分為四個層級,稱為 LEGB:L 是 local(區域),E 是 enclosing(外層函數),G 是 global(全域),B 是 built-in(內建)。當程式需要找某個變數時,會依照 LEGB 順序由內往外找:先找區域變數,找不到就往外層函數找,再找不到就找全域變數。值得一提的是,enclosing 這層設計是 Python 2.2 版才加入的,在那之前函數內的函數會直接跳過外層函數去找全域變數。
Python 的變數查找遵循 LEGB 規則:先找 Local(區域),再找 Enclosing(外層函數),接著 Global(全域),最後才是 Built-in(內建)。以 print 為例,當程式執行時會依序在這四個範圍尋找,直到找到為止。這也解釋了為什麼 list、str、int 這些內建函數可以被覆寫,因為它們的優先順序最低。如果你在某個範圍內定義了同名變數,就會「攔截」內建函數。不過這不代表完全不能用這些名稱,只要確定該範圍內不會呼叫到原本的內建函數,就不會有問題。理解 LEGB 規則後,就能清楚知道變數會在哪裡被找到,避免命名衝突造成的錯誤。
Python 函數內的變數賦值行為有特殊規則。當在函數裡寫 `A = 2` 這樣的賦值語句時,Python 會將它視為「宣告區域變數」,而非修改外層變數。這是為了避免意外覆蓋外部變數。因此,若在函數內寫 `A += 1`,等號左邊會宣告區域變數 A,但等號右邊卻要讀取尚未賦值的 A,導致錯誤。Python 會先將程式碼編譯成 bytecode,此時變數已存在於該 scope 中,只是尚未賦值。即使賦值寫在後面,該變數仍會被視為區域變數,因此無法按 LEGB 規則往外層查找。這個規則需要特別注意。
Python 中變數的作用域遵循 LEGB 規則。當需要在函數內修改全域變數時,可使用 `global` 關鍵字,宣告後該變數就會指向最外層的全域變數。若是巢狀函數想存取外層函數的變數,則使用 `nonlocal` 關鍵字,它只會往上層函數尋找,不會跳到全域範圍。兩者的差異在於:`global` 直接指向最外層全域變數,`nonlocal` 則是逐層往外找,但止於函數範圍,不會觸及全域。由於 Python 的變數宣告與賦值語法相同,透過這些關鍵字才能明確區分「宣告新變數」與「修改既有變數」的意圖。
方法(Method)和函數(Function)在 Python 裡都是用 `def` 關鍵字定義的,本質上都是函數,但使用方式不同。方法必須透過物件來呼叫,例如 `numbers.sort()` 是對 `numbers` 這個物件呼叫 `sort` 方法;而函數可以直接呼叫,例如 `sorted(numbers)` 是把串列當參數傳入。兩者雖然都能排序,但行為不同:`sort()` 方法會直接修改原本的串列,`sorted()` 函數則會回傳一個新的排序後串列,不會動到原本的資料。方法通常定義在類別裡面,跟著物件走,這部分在物件導向章節會有更詳細的說明。
表達式(expression)和陳述句(statement)是程式語言中的基本概念。表達式像是單字或片語,例如數字 18、字串 "Hello Kitty" 或比較運算 1450 > 9527,它們都會產生一個結果。陳述句則是完整的句子,像 `cat = 5` 或 `if cat > 0`,本身不會有結果。一個陳述句通常由多個表達式組成,就像人類語言中一句話由多個單字片語組成。if、for、while、def、class 這些關鍵字都屬於陳述句。理解這個區別對於後續學習 lambda expression 會有幫助,也能更容易看懂其他人寫的進階程式碼。
表達式(expression)與陳述句(statement)是理解 lambda 的基礎。lambda 是希臘字母,在 Python 中用來建立匿名函數。一般定義函數用 def,但無法像 JavaScript 那樣在宣告變數時同時定義函數。lambda 解決了這個問題,語法是 `lambda 參數: 表達式`,例如 `add = lambda a, b: a + b`。關鍵是冒號後面必須是表達式,不能放 return 這類陳述句,否則會語法錯誤。lambda 可以不帶參數,像 `lambda: 1` 或 `lambda: None`,雖然沒什麼實用價值,但語法上是合法的。使用時跟一般函數一樣,加小括號傳入參數即可。
Lambda 表達式只能包含一個表達式,不適合複雜計算,否則程式碼會變得難以閱讀。Lambda 裡面不能做賦值,因為賦值是 Statement 而非 Expression,這會造成語法錯誤。同樣地,型別註記也無法在 Lambda 中使用。Lambda 的參數用法和一般函數相同,可設定預設值、使用關鍵字引數,也能用星號收集位置引數或關鍵字引數。Lambda 常見的應用場景是搭配 sorted 的 key 參數、map、filter 等函數使用。它的優點是不用想名字、只用一次就丟棄,語法也較精簡。不過因為 Python 有串列推導式,我自己用 Lambda 的機會其實不多。
在函數內宣告的區域變數,執行完後就會消失,直接存取會出現「未定義」錯誤。若想讓函數記住變數又不想用 global 污染全域,可以使用 closure(閉包)這種程式設計手法。在介紹閉包前,先認識「自由變數」:當內層函數使用到外層函數的變數時,該變數對內層函數而言就是自由變數。每個函數都有 `__code__` 屬性,其中的 `co_freevars` 可查看該函數的自由變數有哪些。這只發生在 enclosing 情況,全域變數不算。理解自由變數的概念後,接下來就能學習如何透過閉包把變數「帶著走」。
在函數內定義的區域變數,照理說函數執行完就會消失。但閉包打破了這個規則。當一個函數回傳另一個內部函數時,內部函數會把它用到的變數「帶著走」。即使外層函數已經執行完畢,內部函數仍然可以存取那些變數。這些被帶走的變數叫做「自由變數」,不受一般 scope 規則限制,可以在程式中持續存在,直到程式結束為止。閉包讓變數的生命週期延長,能在不同地方被引用或使用。
Python 的閉包透過 Cell Object 機制實現。當某個變數被多個不同的 scope 參照時,Python 會自動建立 Cell Object。例如外層函數的變數 A 若被內層函數使用,這兩處的 A 都會指向同一個 Cell Object,再由 Cell Object 指向實際的值。這就是閉包能「記住」外層變數的原因:當外層函數執行完畢,區域變數雖然消失了,但 Cell Object 仍然存活。內層函數實際上是指向 Cell Object 而非已消失的變數,所以依然能存取該值。透過 `__code__.co_cellvars` 可查看哪些變數成為 Cell Object,而 `__closure__` 屬性則能取得閉包中的 Cell Object 內容。
閉包可以用來建立私有變數,避免全域變數過於開放而被意外修改。由於閉包會記住環境中的自由變數,我們可以利用這個特性製作計數器。實作方式是定義一個外層函數,在裡面宣告 count 變數並初始化為 0,接著定義內層函數,使用 nonlocal 關鍵字標註 count,讓內層函數能存取外層的變數並進行遞增。每次呼叫 create_counter 都會產生一個獨立的計數器,各自維護自己的狀態,互不干擾。這就是閉包的實用價值,而接下來要介紹的裝飾器,也是透過閉包原理實作出來的。
函數裝飾器(Decorator)在 Python 中相當實用,特別是在 Flask 或 Django 網站開發時經常使用。裝飾器的核心概念是將一個函數傳入另一個函數,經過包裝後再回傳。以 deprecated 裝飾器為例,在內部定義一個 wrapper 函數,利用閉包原理保留原本傳入的函數,並在執行前加入警告訊息。當呼叫被裝飾的函數時,會先印出「此函數即將棄用」的提示,再執行原本的功能。重點在於回傳的是函數本身而非執行結果,等到實際呼叫時才會觸發整個流程。
Python 的 Decorator 裝飾器可以透過語法糖讓寫法更簡潔。語法糖的概念就像藥外面包了一層糖衣,吃起來比較順口,但本質沒變。原本使用裝飾器需要先把函數包起來再呼叫,寫法較囉嗦。Python 提供 `@` 符號作為語法糖,只要在函數定義前加上 `@decorator_name`,就等同於原本的包裝寫法,兩者效果完全相同。這種寫法更簡潔好看,不用每次都手動包裝,在使用別人的套件或框架時特別常見且實用。
裝飾器要能重複使用,必須處理被裝飾函數可能有不同參數的情況。當被裝飾的函數需要參數時,原本沒有參數的 wrapper 函數會因為引數數量不符而出錯。解決方法是在 wrapper 函數中使用 `*args` 和 `**kwargs`,前者接收所有位置引數,後者接收所有關鍵字引數。wrapper 收到這些參數後,再原封不動傳給原本的函數執行。這樣不管被裝飾的函數有幾個參數、是位置引數還是關鍵字引數,裝飾器都能正常運作,達到真正可重複使用的目的。
裝飾器可以帶參數,例如傳入一個理由說明為何棄用某功能。當裝飾器後面有括號帶參數時,代表這個裝飾器本身會回傳另一個裝飾器,形成「大層包小層」的結構。實作時需要多包一層函數:最外層接收參數(如 reason),內層才是真正接收被裝飾函數的 decorator。另一個問題是,當裝飾器不帶參數時會出錯,因為被裝飾的函數會被當成參數傳入。解法是用 callable() 判斷傳入的是函數還是字串,再做相應處理。Flask 的 `@app.route()` 就是這種帶參數裝飾器的實例。裝飾器寫起來燒腦,但用起來很方便。
遞迴是函數自己呼叫自己的技巧。當函數執行時會被放到 call stack(呼叫堆疊)上,執行完才移除。如果函數內又呼叫其他函數,新函數會疊上去,完成後再一層層移除。當遞迴沒有終止條件時,堆疊會不斷往上疊,但因為記憶體有限,疊滿就會發生 stack overflow(堆疊溢位),這也是知名程式問答網站 Stack Overflow 名稱的由來。Python 預設堆疊上限是 1000 層,超過就會拋出 recursion error。雖然可以透過 sys 模組調整上限,但若程式邏輯沒有出口,終究還是會滿出來。
費波納西數列是經典的遞迴範例,每一項等於前兩項的和。可以用迴圈實作:設定初始值 A=0、B=1,透過不斷交換並相加來產生數列。也可以用遞迴寫法:定義基底情況(第 0 項為 0、第 1 項為 1),其餘則回傳 fib(N-1) + fib(N-2)。遞迴寫法更接近數學公式,看起來簡潔漂亮,但要注意必須有出口條件,否則會造成 stack overflow。遞迴的本質是拆解問題,把大問題分解成小問題。雖然我蠻喜歡遞迴的設計,但實務上用迴圈的機會比較多。
遞迴寫法雖然優雅,但效能是個問題。以費波納西數列為例,當 N 逐漸增大時,遞迴會不斷分裂成更多子問題,堆疊數量呈指數成長。實測在 M1 電腦上,N 到 35 以上就明顯感覺到延遲。相較之下,迴圈寫法只是不斷交換變數,即使 N 等於 50 也瞬間算完。雖然有「尾遞迴呼叫」這種最佳化技巧可以改善效能,但 Python 本身不支援也不打算支援這個功能,所以學了也用不上。結論是,我個人喜歡遞迴的設計方式,但考量到 Python 的限制,處理較大數字或複雜程式時,還是會斟酌使用。
Python 的 Generator(產生器)是一種惰性運算機制,使用 yield 關鍵字來實作。與一般函數用 return 直接回傳整個結果不同,yield 會暫時交出控制權並回傳一個值,下次呼叫時再從中斷處繼續執行。透過內建的 next() 函數可以逐一取值,像擠牙膏一樣分批獲取資料。當所有值都取完後會拋出 StopIteration,但 for 迴圈或串列推導式會自動處理這個例外。產生器的優勢在於不需要一次將所有資料載入記憶體,特別適合處理大量資料的情境,能有效節省記憶體使用量。
偏函數(partial function)和柯里化函數(currying)都能實現「分期付款」式的參數傳遞。偏函數讓我先給部分參數,剩下的之後一次付清,Python 的 functools 模組已內建 partial 函數可直接使用。柯里化函數則是每次只傳一個參數,一次付一期,直到所有參數都給齊才執行函數並回傳結果。這兩個概念在一般網站開發或爬蟲工作中較少用到,但作為函數式程式設計的知識還是值得了解。
Python 裡常見的錯誤類型:SyntaxError 是語法錯誤,通常是括號或冒號寫錯;IndentationError 是 Python 特有的縮排錯誤;NameError 是變數或函數不存在,可能與作用域有關;TypeError 是型別錯誤,例如數字與字串相加;ValueError 是值的轉換錯誤;ZeroDivisionError 是除以零的錯誤;IndexError 是串列索引超出範圍;KeyError 是字典中找不到對應的 key;FileNotFoundError 是檔案不存在;AttributeError 是存取物件不存在的屬性。最後區分 Error 和 Exception 這兩個名詞,雖然常被混用,但實際上有所不同。
錯誤是比較廣泛的概念,包含語法錯誤等無法恢復的問題;例外則是程式本身沒問題,但執行過程中因外部因素(如使用者輸入零當分母)而產生的狀況,通常可以處理並恢復。不同程式語言對此有不同定義,像 Java 會在名稱加上 Exception 後綴來區分,但 Python 的設計較特別,雖然名稱多用 Error 結尾,但這些錯誤類別其實都繼承自 Exception,形成「錯誤是例外的子集合」這種獨特架構。不論是錯誤還是例外,工程師都必須想辦法處理這些問題。
透過 raise 可以丟出 NameError 等內建錯誤,並自訂錯誤說明文字。雖然 Python 內建的錯誤類型已經夠用,但在較大型的專案中,建議自訂專屬的錯誤類別。做法是繼承 Exception 類別,例如建立 PokemonCardError,這樣當錯誤發生時,從錯誤名稱就能更清楚知道問題所在,像是「噴火龍卡片無法使用」這類專案專屬的錯誤訊息,比起一般的語法錯誤更容易理解和除錯。關於類別和繼承的概念,會在後續物件導向章節詳細說明。
當程式可能出錯時,例如除以零,可以用 try 把可能出錯的程式碼包起來,萬一出錯就跳到 except 區塊處理,程式不會直接當掉。不建議把所有程式都包進 try,因為身為工程師應該知道哪裡可能出錯。except 後面可以指定特定錯誤類型,例如 ZeroDivisionError 或 NameError,針對不同錯誤做不同處理。最後還可以加一個不指定類型的 except 當作最後防線,接住所有未預期的錯誤。這種做法跟 if/else 主動檢查不同,try/except 的概念是「先做再說,出錯再救」。
finally 區塊不論 try 成功或失敗都會執行,適合用來做收尾動作,例如關閉檔案或中斷資料庫連線。else 區塊則是在 try 沒有出錯時才會執行。完整組合為 try、except、else、finally。最後分享一個冷知識:當 try 區塊中有 return,finally 仍會優先執行,因此若 finally 中也有 return,最終會回傳 finally 的值。不過這種寫法不建議使用,僅供了解優先順序。下個章節將介紹 Python 的模組與套件。
隨著程式碼越寫越多,我們需要方法來組織這些程式碼,就像整理收藏品一樣。在 Python 中,模組就是一個 .py 檔案,裡面可以包含函式、變數、類別等;套件則是一個資料夾,裡面可以包含多個模組。使用 import 關鍵字可以匯入模組,例如 import math 可以使用數學相關功能。若模組不存在,會出現 ModuleNotFoundError 錯誤。透過 sys.path 可以查看 Python 搜尋模組的路徑順序,它會從當前目錄開始,依序往下尋找,直到找到或報錯為止。
Python 的 sys.path 是一個串列,決定了直譯器搜尋模組的順序。啟動虛擬環境後,sys.path 會自動調整,把虛擬環境的路徑加到搜尋清單中。這就是為什麼在虛擬環境裡安裝的套件只有在該環境下才找得到。不管是用 venv 還是 Poetry 建立虛擬環境,背後的原理都一樣:透過修改 sys.path,讓 Python 知道要去哪裡找套件。理解這個機制,就能明白虛擬環境如何做到套件隔離。
建立模組的方式很簡單,只要建立一個 .py 檔案,在裡面定義函數或變數即可。要使用模組時,用 `import` 關鍵字匯入,不需要加 `.py` 副檔名。Python 會從當前目錄開始搜尋模組。如果模組放在子目錄中,匯入時要用 `目錄名.模組名` 的格式,也可以用 `as` 給模組取別名來簡化使用。模組中不只函數可以被使用,變數也可以。特別要注意的是,匯入模組並不是單純的複製或連結,而是實際執行該 .py 檔案中的所有程式碼,這點很容易被忽略。
使用 `from` 關鍵字可以從模組中匯入特定的函數或變數,例如 `from greeting import hi`,這樣就能直接使用 `hi()` 而不需要寫 `greeting.hi()`。若要匯入多個項目,可用逗號分隔,如 `from greeting import hi, hey, ABC`。雖然也能用星號 `*` 匯入全部內容,但這種做法不建議,因為會讓程式碼難以追蹤變數來源。另外要注意,不論只匯入一個還是多個項目,Python 都會完整執行整個模組檔案,並不會因此節省資源,差別只在於當前命名空間中可直接使用哪些名稱。
Python 匯入模組時,同一個模組只會被載入一次。實驗證明,當模組中有 print 語句,第一次 import 時會印出內容,但後續重複 import 則不會再執行。不論使用 `import package.module`、`from package import module`,或 `from package.module import function`,Python 都認的是同一個檔案,不會重複載入。透過 `sys.modules` 可以查看所有已載入的模組清單,確認模組確實只載入一次。因此在不同程式中重複使用同一模組,不會造成資源浪費,因為模組一旦載入記憶體就會持續存在。
Python 的 import 語法沒有強制規定只能放在檔案最上方,放在函式內部也可以正常運作。不過要注意的是,在函式裡面 import 的模組會變成區域變數,只在該函式範圍內有效,離開函式後就無法使用。可以用 locals() 函數來確認,會看到 import 進來的模組確實被當作區域變數存在於該函式的命名空間中。雖然技術上沒有限制 import 的位置,但放在檔案最上方是慣例做法,放在其他地方通常沒有實際意義。
當匯入的函數與本地定義的函數同名時,Python 會依據程式碼順序決定執行哪一個,後定義的會覆蓋前面的。解決命名衝突有幾種方法:第一,直接修改本地函數名稱避免衝突;第二,匯入時使用 `as` 關鍵字給予別名,例如 `from module import Hey as Hey456`,讓兩個函數能在同一空間共存;第三,使用完整模組路徑呼叫,例如 `hello_kitty.greeting.Hey()`,雖然可行但較為冗長。建議避免修改第三方套件的函數名稱,因為這會影響其他使用者,最佳做法是在匯入時設定別名。
Python 在匯入模組時會依照 sys.path 的順序搜尋,而第一個搜尋位置是當前目錄。如果在專案目錄中建立一個與內建模組同名的檔案,例如 math.py,Python 會優先匯入你的檔案而非內建模組,這可能導致程式出錯。像 abc 也是 Python 內建模組,若不小心用了同名檔案,VS Code 會提示建議更名。解決方法是避免使用內建模組的名稱,或者將自訂模組放進子資料夾中,例如 utilities/math.py,再透過 utilities.math 的方式匯入,就不會與內建模組衝突。
Python 的 module 沒有真正的 private 設計,任何看得到的 function 都可以被 import 進來。不過有個小技巧:在 function 名稱前加上底線,例如 `_hello`,這樣使用 `from module import *` 時,帶底線的 function 就不會被匯入。但這只能躲過星號的匯入方式,如果直接指定名稱 `from module import _hello`,還是可以正常使用。所以底線只提供一點點的隱私保護,Python 本質上並沒有私有 function 或私有變數的機制。
Python 的 `__name__` 變數會根據執行方式而有不同的值。當檔案被單獨執行時,`__name__` 的值是 `__main__`;當檔案被當作模組匯入時,`__name__` 則會是模組名稱。這個特性可以解決一個常見問題:模組中的測試程式碼在被匯入時也會跟著執行。利用 `if __name__ == '__main__':` 這個條件判斷,就能讓測試程式碼只在檔案被單獨執行時才運作,避免在匯入模組時產生不必要的輸出。這是 Python 中非常實用且常見的寫法。
Python 中的套件(package)本質上也是一種模組,差別在於套件具有 `__path__` 屬性。套件可以像一般模組一樣被 import,而套件的程式碼要寫在 `__init__.py` 這個特殊檔案中。當 import 套件內的任何模組時,`__init__.py` 都會自動被執行,即使你只是要 import 套件內更深層的模組,沿途每一層的 `__init__.py` 都會依序載入。這種頭尾有雙底線的命名方式稱為 dunder(double underscore 的縮寫),在物件導向程式設計中會經常出現。
Python 3.3 之前,資料夾必須包含 `__init__.py` 檔案才能被視為 package,否則無法 import。現在這個限制已經取消,沒有這個檔案也能正常運作。雖然 `__init__.py` 通常是空的,但它本質上就是一個 `.py` 檔,可以在裡面定義變數或函數,這些內容都能被 import 使用。當我們 import 某個 package 內的模組時,該 package 的 `__init__.py` 也會被隱含載入。所以 package 不只是資料夾,它本身也是一個模組,用來管理其他模組。
Python 的 `__init__.py` 檔案在 3.3 版之後已非必要,但它區分了兩種套件類型:常規套件(Regular Package)與命名空間套件(Namespace Package)。命名空間套件的特點是可以跨不同目錄共享同一個命名空間,透過修改 `sys.path` 將多個路徑加入搜尋範圍後,不同資料夾中同名的套件可以合併使用。但只要其中一個目錄加上 `__init__.py` 變成常規套件,這個共享效果就會失效。對一般開發者來說,通常不需要跨專案共享命名空間,加上某些測試框架會依據 `__init__.py` 判斷哪些目錄需要掃描,所以建議還是保留這個檔案。
Python 的 sys.path 搜尋路徑中,除了一般目錄外,其實也可以包含 zip 壓縮檔。只要將壓縮檔路徑加入 sys.path,就能直接 import 壓縮檔內的模組。實作方式是透過 sys.path.append() 加入 zip 檔路徑,之後就能像一般模組一樣匯入使用。如果覺得每次手動 append 很麻煩,可以透過設定環境變數 PYTHONPATH 來達成同樣效果。在 Mac 或 Linux 上直接設定即可,Windows 則使用 set PYTHONPATH 指令。這種方式比進入 Python 後再修改 sys.path 更為方便。
物件導向程式設計的核心概念其實很單純,目的是讓我們用日常生活中能理解的方式來組織程式碼。物件就是「一個東西」,可以是車子、小鳥,或任何看得到、摸得到的事物。每個物件都有兩個重要特徵:狀態和行為。以人為例,狀態包括黑頭髮、年齡、膚色;行為則是講話、吃飯、走路等動作。透過這種擬人化、擬物化的思維,我們能更清楚地管理程式碼。而要建立物件,就需要用到「類別」,可以把它想像成一張設計圖或模具。
class 就像雞蛋糕烤盤,是用來製作物件的模板。烤盤本身是 class,而烤出來的每一個雞蛋糕就是實體(instance)。在 Python 中,使用 class 關鍵字建立類別,例如定義一個 Cat 類別後,就能透過呼叫它來產生多個實體,像是 kitty 和 nancy,它們各自佔用不同的記憶體位置。可以用內建的 isinstance() 函數檢查某個物件是否為特定類別的實體。另外,透過物件的 `__class__` 屬性,能查看該物件是由哪個類別產生的。這就是物件導向程式設計中 class 與 instance 的基本概念。
透過類別建立實體時,若想讓每個物件有不同屬性,需使用 `__init__` 方法。這是 Python 內建的特殊方法,會在建立實體時自動執行。`__init__` 的第一個參數必須保留給物件本身,慣例上命名為 `self`,Python 會自動將實體傳入。後續參數則用來接收自訂的屬性值,如名字、顏色、年齡等。在方法內透過 `self.屬性名 = 值` 的方式,將傳入的資料綁定到物件上。如此一來,每個實體就能擁有獨特的屬性,而非千篇一律的預設值。
Python 的 `__init__` 方法常被稱為建構子,但嚴格來說並不精確。當我們呼叫類別建立實體時,Python 會先透過 `__new__` 方法產生一個空白物件,接著才執行 `__init__` 將名字、顏色等資料灌入物件中。因此 `__init__` 的功能是初始化,而非建立物件。至於為什麼 `__init__` 需要明確帶入 `self` 參數,這源自 Python「明瞭優於隱晦」的設計哲學,不像 JavaScript 的 `this` 會隱式存在且指向不固定。Python 要求參數數量必須精確對應,這種明確的設計風格讓程式碼更容易理解與維護。
Python 類別的 `__init__` 方法可以執行任何操作,但最常見的用途是將傳入的參數指定給物件,讓資料保留在物件中。透過點語法如 `kitty.name` 可以存取或修改屬性,也能用 `__dict__` 查看物件的所有屬性。每個物件的狀態是獨立的,修改一個物件不會影響其他物件。這些存在於物件中的變數稱為「實體變數」。除了點語法,也可使用 `getattr` 和 `setattr` 函數存取屬性,特別適合在迴圈中動態設定屬性名稱的情境。
物件導向程式設計透過擬人化、擬物化的方式組織程式碼,讓我們知道方法和變數該放在哪裡。用 class 建立類別後,可以產生多個實體。當我們在實體上呼叫方法時,這就叫做實體方法(Instance method)。實體方法必須定義在類別內部,且第一個參數慣例上使用 `self`,因為 Python 會自動將物件本身帶入。透過 `self` 可以存取物件的屬性,例如 `self.name`。實體方法本質上就是定義在類別裡的函數,之前學過的預設值、位置引數、關鍵字引數等技巧都能使用。
在 Python 裡,幾乎所有東西都是物件,包括類別本身也是物件。這裡要特別注意物件(object)和實體(instance)的差異:實體是類別生出來的東西,而物件是更廣泛的概念,數字、字串、串列、字典、類別都是物件。既然類別也是物件,我們可以在類別裡定義類別變數(class variable),它屬於類別本身而非實體。若要在實體方法中存取類別變數,必須透過類別名稱來呼叫,例如 `Cat.count`,因為類別和實體是不同體系的。這種方式常用於計算類別被實體化的次數。
Python 中透過實體存取類別變數時,會先搜尋實體本身的 `__dict__`,找不到才往上查詢所屬類別的 `__dict__`,若仍無結果則繼續向上層類別尋找,直到最終回傳 AttributeError。這與其他程式語言直接報錯的設計不同。另外,讀取與寫入的行為也不一樣:讀取時會逐層向上搜尋,但寫入時則直接在該實體的字典中新增或修改屬性,不會影響類別變數本身。因此,當對實體設定與類別變數同名的屬性時,實際上是在實體字典中建立新的 key,而非修改類別變數。
類別的實體變數可以透過點語法直接讀取和修改,但這樣做有風險,例如把年齡設成 1000 歲這種不合理的值。許多物件導向語言有 `private` 或 `protected` 修飾詞來保護屬性,但 Python 沒有這種設計,所有看得到的東西都能改。為了做基本防護,可以使用 getter 和 setter 方法。getter 負責回傳屬性值,setter 負責設定值並可加入驗證邏輯,例如限制年齡必須在 1 到 120 之間。慣例上會在屬性名稱前加底線,提醒其他開發者不要直接存取。不過這只是消極防護,加了底線的屬性仍然可以被直接修改。
Python 的 Property 裝飾器能讓 Getter 和 Setter 的使用更簡潔。一般方法需要加小括號呼叫,但透過 `@property` 裝飾器,可以像存取屬性一樣直接使用,例如用 `hero.age` 取代 `hero.get_age()`。Property 本身是一個內建類別,裝飾後會將方法包裝成屬性物件,內部包含 fget、fset 和 fdelete。設定 Setter 時,使用 `@age.setter` 裝飾器包裝設定方法,就能用 `hero.age = 20` 這種語法賦值,同時保留驗證邏輯。這種寫法讓程式碼看起來像一般屬性操作,實際上仍在執行方法中的檢查邏輯。
Python 並沒有真正的私有變數設計,物件內的所有屬性都可以從外部存取。雖然有些教學資料會說在變數前加兩個底線就能實現私有屬性,但這其實是錯誤的觀念。Python 只是透過「名字改編」(Name Mangling)機制,將變數名稱改成「底線 + 類別名稱 + 原變數名」的格式,透過物件的字典仍然可以取得。這種設計有好有壞,好處是方便,壞處是有風險。有人批評這缺乏封裝概念,但我認為這屬於自由派的設計哲學,只要知道規則並謹慎使用即可。
Python 的自由度很高,可以在任意物件上動態新增屬性,這些屬性會存放在物件的 `__dict__` 字典中。若想限制屬性的任意新增,可在 class 中定義 `__slots__`,指定允許的屬性名稱。設定後,物件的 `__dict__` 會被移除,只能使用 slots 中定義的屬性,其他屬性會報錯。這樣做除了限制屬性外,也能減少記憶體消耗、提升效能。不過使用時要注意繼承問題:若父類別有設定 slots 但子類別沒有,子類別仍會有字典,限制就失效了。像 SQLAlchemy 這類套件就運用了 slots 來最佳化效能。
實體方法是作用在實體身上的方法,會將實體作為第一個參數傳入。類別方法則是作用在類別身上的方法,需要使用 `@classmethod` 裝飾器來定義,呼叫時會將類別本身作為第一個參數傳入,慣例上用 `cls` 接收。單純定義在類別中的函式並不是類別方法,它只是放在類別這個「箱子」裡的普通函式,與類別沒有綁定關係。經過 `@classmethod` 裝飾後,方法會變成 bound method,與類別綁定。值得注意的是,類別方法也可以透過實體來呼叫。另外,在 Python 2 中直接呼叫類別內的普通函式會報錯,但 Python 3 則沒有這個問題。
Python 的靜態方法使用 `@staticmethod` 裝飾器定義,與類別方法和實體方法不同。一般定義在類別內的函數,若透過實體呼叫會自動傳入 `self`,導致參數數量錯誤。靜態方法則不會自動綁定任何東西,不論用類別或實體呼叫都不會傳入額外參數。它就像一個純淨的函數,只是剛好存放在類別這個「箱子」裡,與類別或實體都沒有關聯,不會主動綁定任何對象。
封裝是物件導向設計的重要概念,可將函數放入類別中並控制存取權限,但 Python 沒有真正的 `private` 設計,所有放入類別的東西都能被存取。繼承則像生物分類法,界門綱目科屬種的概念,靈長目有五指對握的共同特徵,人類和猴子都屬於靈長目,所以都具備這個能力。在 Python 中,繼承的寫法是在類別名稱後加小括號並放入上層類別名稱,子類別就能自動擁有上層類別的方法。繼承的意義是「分類自」而非「繼承家產」,與誰生的無關,只跟分類體系有關。透過繼承可以把共同的屬性和方法放在適當的分類層級,避免產生什麼都有的「神之類別」。
Python 中即使類別定義沒有明確寫繼承,實際上都會繼承內建的 object 類別。透過 `__mro__` 屬性或 `mro()` 方法,可以查看類別的方法解析順序(Method Resolution Order),了解從子類別到最上層 object 的完整繼承鏈。object 類別提供了 `__new__`、`__init__` 等基本方法,讓所有類別都能正常運作。在 Python 3 中,寫不寫 `class Cat(object)` 效果相同,但 Python 2 則有差異。對 object 查詢 `__bases__` 會得到 `None`,代表它是真正的最頂層類別。
當物件尋找方法或屬性時,會先從自身字典開始,找不到就往所屬類別找,再往上層類別找,直到找到或報錯。若子類別與父類別有同名方法,會優先使用子類別的版本,這稱為 override(複寫)。複寫並非消滅父類別方法,只是查找過程中先找到子類別就停止了。若想在子類別中呼叫父類別方法,可直接用類別名稱呼叫並傳入 `self`,但更好的做法是使用 `super()`。`super()` 會建立一個代表父類別的實體,讓我們能直接呼叫父類別方法而不需手動傳入 `self`,且當父類別改變時也不用修改程式碼。
Python 的 `issubclass()` 函數可檢查類別之間的繼承關係,判斷某個類別是否為另一個類別的子類別。例如建立 Animal、Mammal、Cat、Bird 四個類別,Cat 繼承自 Mammal,Mammal 繼承自 Animal,Bird 直接繼承 Animal。使用 `issubclass(Cat, Animal)` 會回傳 True,因為它們在繼承體系上有上下層關係。另外,`isinstance()` 則用於檢查實體是否屬於某個類別,比用 `type()` 判斷更方便,因為它能涵蓋整個繼承鏈。繼承關係稱為「kind of」,而實體化則是「is a」,這兩個概念要區分清楚。
Python 支援多重繼承,與其他語言採用介面或模組混搭的方式不同。單一繼承的架構雖然簡單乾淨,但若希望一個類別同時擁有多種能力,例如讓貓會飛又會潛水,就會遇到限制。在 Python 中,只需在類別定義時用逗號分隔多個父類別,就能同時繼承它們的功能。這樣的設計相當方便簡潔,但也衍生出鑽石問題:當多個父類別都有相同名稱的方法時,子類別該呼叫哪一個?這是多重繼承需要特別注意的地方。
當多重繼承時,如果多個上層類別有相同方法,Python 會透過 MRO(Method Resolution Order,方法解析順序)來決定呼叫哪一個。以貓同時繼承鳥類和魚類為例,當呼叫 `sleep` 方法時,Python 會依照 MRO 順序尋找:先找自己,再依繼承順序找上層類別。這種多重繼承的結構稱為鑽石問題或菱形繼承。super 函數實際上不是指上層類別,而是 MRO 中的下一個類別。MRO 的計算使用 C3 線性演算法,只要理解這個機制,多重繼承用起來其實很直覺方便。
講師介紹
五倍學院負責人,在國內外各大型技術研討會擔任講者,參與過日本 RubyKaigi、日本 Ruby World Conference、臺灣微軟 Azure Developer Day 、RubyConf Taiwan、JSDC、WebConf Taiwan 等。有二十年程式開發經驗和十多年的教學經驗,在臺灣推廣 Ruby 及 Git 多年,在各大專院校與企業開課,深受學員喜愛。
非資訊本科系出身,但喜歡寫程式,而且希望可以寫一輩子程式的電腦阿宅。
著有「為你自己學 Git」與「為你自己學 Ruby on Rails」暢銷技術書。
高見龍 的其他課程
推薦課程
你可能也會喜歡的學習內容
線上課程
廖珀均 aka 奶綠茶
AI 驅動程式設計:工程師的 AI Agent 實戰工作坊
線上課程
FP101
蘇泰安
工作上用得到的函數式程式設計
線上課程
高見龍