Chapter 01

Python 深入(附 Go 速覽)

從 CPython 怎麼執行你的程式、物件與記憶體模型、GIL,一路到 asyncio、型別系統與 web 生態,再補上一份給後端用的 Go 速覽。Appier 面試很愛問「為什麼 Python 會這樣」,本章的目標是讓你能從底層機制講出答案,而不是背結論。

預估閱讀 75 分鐘

CPython 執行模型:從 .py 到 bytecode 到 PVM 進階

「Python 是直譯語言」這句話只對一半。我們平常用的 CPython(用 C 寫的官方實作)其實會先把原始碼編譯成 bytecode,再交給一個 stack-based 的虛擬機器(Python Virtual Machine,PVM,也就是 ceval.c 裡那個巨大的 eval loop)一條一條執行。

flowchart LR A["source.py"] --> B["Tokenizer"] B --> C["Parser (PEG, 3.9+)"] C --> D["AST"] D --> E["Compiler + 符號表"] E --> F["Code object (bytecode)"] F --> G["__pycache__/*.pyc"] F --> H["PVM eval loop"] H --> I["C runtime / 物件操作"]

編譯流程與 .pyc

  1. Tokenize → Parse:3.9 起改用 PEG parser,產生 AST(可用 ast.parse() 看)。
  2. Compile:建立符號表(決定每個名字是 local、global、free 還是 cell variable),產生 code object(func.__code__),裡面有 co_code(bytecode)、co_consts、co_varnames 等。
  3. 快取:被 import 的模組會把 code object 序列化(marshal)成 __pycache__/mod.cpython-312.pyc,下次若原始碼 mtime 沒變就直接載入,省去編譯。直接執行的主程式(python main.py)不會寫 .pyc。
  4. 執行:PVM 為每次函式呼叫建立一個 frame(含 local 變數陣列、value stack、指令指標),在 eval loop 中 dispatch 每個 opcode。

用 dis 看 bytecode

import dis

def add(a, b):
    c = a + b
    return c

dis.dis(add)
  # Python 3.14 的輸出(不同版本 opcode 會不同)
  3           RESUME                   0

  4           LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
              BINARY_OP                0 (+)
              STORE_FAST               2 (c)

  5           LOAD_FAST_BORROW         2 (c)
              RETURN_VALUE

讀法:左邊是原始碼行號,中間是 opcode,右邊是參數與註解。PVM 是 stack machine:LOAD_FAST 把 local 變數推上 value stack,BINARY_OP (+) 彈出兩個、呼叫 a.__add__(b)(失敗再試 b.__radd__(a)),結果推回去。在 3.11 以前的版本你會看到 LOAD_FAST a、LOAD_FAST b 分開兩條;3.14 把它們融合成一條 superinstruction。

實戰提示:用 dis 回答「哪個比較快」

面試問「[] 跟 list() 哪個快?」— dis 一下:[] 是一條 BUILD_LIST;list() 需要 LOAD_GLOBAL list(先查 globals 再查 builtins)+ CALL,所以 [] 較快。同理,local 變數存取(LOAD_FAST,陣列 index)比 global(dict 查找)快,這就是老派優化「把 global 函式綁到 local 變數」的原因。

3.11+ 的 specializing adaptive interpreter 與 JIT

Python 3.11(PEP 659,Faster CPython 計畫)引入自適應特化:一條指令被執行多次後,會依實際觀察到的型別替換成特化版本,例如 BINARY_OP 在兩邊都是 int 時變成 BINARY_OP_ADD_INT,LOAD_ATTR 變成直接用 offset 讀 instance 屬性的版本。若型別改變則「去特化」退回通用版。這讓 3.11 平均比 3.10 快約 1.25 倍。可用 dis.dis(f, adaptive=True) 看特化後的指令。

3.13 起有實驗性的 copy-and-patch JIT(PEP 744,需編譯時開啟),目前效益仍有限。重點觀念:CPython 仍然是以直譯為主,每個 opcode 都有 dispatch 成本、每個物件操作都要走動態查找,這是純 Python 迴圈比 C 慢 10–100 倍的根本原因。

實作特色何時考慮
CPython官方實作、C 擴充生態最完整、有 GIL(free-threaded build 除外)預設選擇
PyPytracing JIT,純 Python CPU-bound 程式常快數倍;C 擴充相容性較差大量純 Python 運算、少用 C 擴充
CythonPython 超集合,加型別宣告後編譯成 C 擴充熱點函式要接近 C 速度
Jython / IronPython跑在 JVM / .NET,停留在舊版本幾乎不再使用
面試官想聽到什麼

「Python 是編譯還是直譯?」→「CPython 先把原始碼編譯成 bytecode(存在 .pyc),再由 stack-based 的虛擬機器直譯執行。3.11 之後 interpreter 會依執行期型別做 specialization,3.13 有實驗性 JIT。慢的原因是動態型別帶來的 dispatch 與屬性查找、boxed 物件,以及每條指令的直譯成本。」再補一句「所以熱點要嘛向量化(NumPy)、要嘛下放到 C/Rust 擴充」,就是一個完整答案。

物件模型:一切皆物件、identity 與參數傳遞 高頻

在 CPython 裡,每個值都是一個 heap 上的 C 結構 PyObject,至少包含兩個欄位:reference count(ob_refcnt)與型別指標(ob_type)。整數、函式、類別、模組、甚至 None 都是物件。變數不是「裝值的盒子」,而是貼在物件上的名字標籤(name binding)。

x = 42
print(type(x), type(type(x)), type(len))   # <class 'int'> <class 'type'> <class 'builtin_function_or_method'>
print(isinstance(type, object), isinstance(object, type))  # True True(type 與 object 互相纏繞)

def f(): pass
f.custom_attr = "函式也是物件,可以掛屬性"
print(f.__code__.co_name, f.custom_attr)

identity(is / id)vs equality(==)

id(obj)
物件在其生命週期內唯一的整數;CPython 中就是記憶體位址。物件被回收後 id 可能被重用。
a is b
比較 identity:是不是同一個物件。只是指標比較,不能被 overload,非常快。
a == b
比較 value:呼叫 a.__eq__(b),可以自訂。預設(object 的實作)退化成 identity。

規則:跟 singleton(None、True/False、Ellipsis、sentinel 物件)比較用 is,其餘都用 ==。PEP 8 也這樣要求。用 == None 的風險是某些物件(例如 NumPy array)會 overload __eq__ 回傳非 bool。

small int cache 與字串 interning

a = 256; b = 256
print(a is b)          # True:CPython 預先建立 -5 ~ 256 的整數並重複使用

x = int("257"); y = int("257")
print(x is y)          # False:執行期產生的兩個不同物件
print(x == y)          # True

# 注意:同一個 code object 裡的相同常數會被合併,所以下面在 .py 檔中可能是 True
p = 257; q = 257
print(p is q)          # 在同一個模組/函式中編譯 → 常數去重 → 通常 True;REPL 分兩行輸入則 False

s1 = "hello_world"; s2 = "hello" + "_world"
print(s1 is s2)        # True:看起來像識別字的字串常數會被 intern,且 "hello"+"_world" 在編譯期常數摺疊

import sys
t1 = "".join(["hel", "lo world!"]); t2 = "hello world!"
print(t1 is t2, sys.intern(t1) is sys.intern(t2))   # False True
常見陷阱

small int cache 範圍(-5 到 256)與 interning 規則都是 CPython 實作細節,不是語言保證。正確答案永遠是「比較值用 ==」。面試官問這題是想看你知不知道 is 比的是 identity,以及「為什麼會有 cache」(小整數極常用,避免反覆配置;dict key 字串 intern 後比較可以先比指標)。sys.intern() 在需要大量重複字串當 key 時(例如解析數百萬行 log 的欄位名)可以省記憶體並加快 dict 查找。

mutable vs immutable

Immutable(不可變)Mutable(可變)
int、float、complex、bool、str、bytes、tuple、frozenset、range、NoneTypelist、dict、set、bytearray、大多數自訂 class 的 instance

「不可變」指物件本身內容不能改;x += 1 對 int 來說是建立新物件再把名字 x 重新綁定。注意 tuple 不可變但可以裝可變物件:t = ([],) 中的 list 仍可 append,這樣的 tuple 也就不可 hash。

t = ([1, 2],)
t[0].append(3)        # OK,t == ([1, 2, 3],)
try:
    t[0] += [4]       # 經典謎題:會丟 TypeError,但 list 其實已經被改了!
except TypeError as e:
    print(e)          # 'tuple' object does not support item assignment
print(t)              # ([1, 2, 3, 4],)
# 原因:t[0] += [4] 展開成 tmp = t[0]; tmp = tmp.__iadd__([4])(就地修改成功); t[0] = tmp(失敗)

參數傳遞:pass-by-object-reference(call by sharing)

Python 既不是 pass-by-value 也不是 C++ 意義上的 pass-by-reference。函式呼叫時,參數名字被綁定到呼叫端傳入的同一個物件。因此:

  • 在函式內就地修改(mutate)可變物件 → 呼叫端看得到。
  • 在函式內重新綁定(rebind)參數名字 → 只影響 local 名字,呼叫端看不到。
def mutate(lst):
    lst.append(99)      # 改的是同一個物件

def rebind(lst):
    lst = [0]           # 只是讓 local 名字指向新物件

nums = [1, 2]
mutate(nums); print(nums)   # [1, 2, 99]
rebind(nums); print(nums)   # [1, 2, 99](沒變)

def inc(n):
    n += 1              # int 不可變,+= 產生新物件並 rebind local 名字
    return n
k = 1; inc(k); print(k)     # 1
面試官想聽到什麼

「Python 是 pass by value 還是 reference?」→ 回答「pass-by-object-reference / call by sharing:傳的是物件參考(可以想成『傳指標的值』)。可變物件被就地修改會反映到呼叫端,重新賦值則不會;不可變物件因為無法就地修改,行為看起來像 pass-by-value。」最好當場寫出上面 mutate vs rebind 的例子。

記憶體管理:reference counting、generational GC 與省記憶體技巧 深入

Reference counting:主要的回收機制

每個物件有 ob_refcnt。名字綁定、放進容器、作為參數傳遞都會 Py_INCREF;名字離開 scope、del、從容器移除會 Py_DECREF。計數歸零的那一刻物件立即被釋放(呼叫 tp_dealloc),所以 CPython 的記憶體回收大部分是確定性的,這也是為什麼 open() 忘了 close 在 CPython 上通常「剛好沒事」(但在 PyPy 上不行,所以仍要用 with)。

import sys
a = []
print(sys.getrefcount(a))   # 2:a 本身 + 傳進 getrefcount 的暫時參考
b = a
print(sys.getrefcount(a))   # 3
del b
print(sys.getrefcount(a))   # 2

優點:即時回收、記憶體峰值低、行為可預期。缺點:(1) 每次賦值都要改計數,有開銷;(2) 多執行緒同時改計數會 race,這正是 GIL 存在的主因之一;(3) 無法處理循環參考。

循環參考與 generational GC

import gc

class Node:
    def __init__(self, name):
        self.name = name
        self.peer = None

a, b = Node("a"), Node("b")
a.peer, b.peer = b, a      # a → b → a 形成環
del a, b                    # refcount 都還是 1,refcounting 回收不了
print(gc.collect())         # 回傳回收的不可達物件數(> 0)

CPython 另有一個 cycle detector(gc 模組),只追蹤「可能形成環的容器物件」(list、dict、自訂 instance 等;int、str 不追蹤)。演算法概念:對候選集合中每個物件,把「來自集合內部的參考」從 refcount 中扣掉,剩下仍 > 0 的代表有外部參考(可達),從它們出發標記可達物件,其餘就是只被環內互相參考的垃圾。

分代(generational)假設「大部分物件很年輕就死」:新物件放在第 0 代,活過一次收集就晉升。傳統 CPython(≤ 3.13)有三代,預設 threshold 為 (700, 10, 10):第 0 代「配置數 − 釋放數」超過 700 觸發 gen0 收集,gen0 收集 10 次觸發一次 gen1,以此類推。3.14 改為 incremental GC(young / old 兩代,每次只掃描 old 的一部分以縮短停頓),在 3.14 上 gc.get_threshold() 會回傳 (2000, 10, 0)。

實戰提示:GC 停頓與調校

對延遲敏感的服務,full collection 掃描大量長壽物件會造成數十毫秒的停頓。常見手法:(1) 啟動載入大量常駐資料(模型、設定、快取)後呼叫 gc.freeze()(3.7+)把現存物件移到永久代,不再被掃描——Instagram 曾公開分享在 pre-fork 的 web server 中搭配 gc.freeze() 減少 copy-on-write 造成的記憶體膨脹;(2) 調高 threshold 降低頻率;(3) 避免製造環(用 weakref 做 back-reference);(4) 用 gc.callbacks 量測每次 GC 耗時。gc.disable() 只停掉 cycle detector,refcounting 仍運作。

__del__ 的陷阱

  • __del__ 是 finalizer,不是 destructor 保證:何時被呼叫不確定(有環時要等 GC;PyPy 上更晚),直譯器結束時甚至可能不呼叫。
  • __del__ 內丟出的例外會被忽略(只印警告到 stderr)。
  • 在 __del__ 中把 self 存到 global 會「復活」物件(resurrection)。
  • 直譯器關閉時,模組的 global 可能已被設成 None,__del__ 裡用到 global 會炸。
  • 3.4 之前,環中有 __del__ 的物件無法回收(放進 gc.garbage);PEP 442 之後可以回收,但呼叫順序不保證。

結論:釋放資源請用 context manager(with)或 weakref.finalize,不要依賴 __del__。

weakref:不增加 refcount 的參考

import weakref

class BigModel:
    def __init__(self, name): self.name = name

cache = weakref.WeakValueDictionary()   # value 被回收後自動從 dict 消失
m = BigModel("ctr-v3")
cache["ctr"] = m
print(cache.get("ctr").name)   # ctr-v3
del m                          # 最後一個強參考消失
print(cache.get("ctr"))        # None(CPython 上立即生效)

# 樹狀結構:child 用 weakref 指向 parent,避免 parent ↔ child 循環
class TreeNode:
    def __init__(self, parent=None):
        self._parent = weakref.ref(parent) if parent else None
        self.children = []
    @property
    def parent(self):
        return self._parent() if self._parent else None

用途:快取(不想讓快取延長物件壽命)、observer/callback 註冊表、打破 parent-child 環。注意 int、str、tuple、list、dict 等內建型別不能直接被 weakref(但它們的子類別可以)。

sys.getsizeof 與 __slots__

sys.getsizeof(obj) 只回傳物件本身的大小(shallow),不含它參考的物件。例如 sys.getsizeof([big_str] * 1000) 只算 list 的指標陣列。要算深度大小需自己遞迴、或用 tracemalloc / pympler。

import sys, tracemalloc

class P:                       # 一般 class:屬性存在 instance 的 __dict__
    def __init__(self, x, y): self.x, self.y = x, y

class PS:                      # __slots__:屬性存在固定 offset 的 descriptor 槽位
    __slots__ = ("x", "y")
    def __init__(self, x, y): self.x, self.y = x, y

for cls in (P, PS):
    tracemalloc.start()
    objs = [cls(i, i) for i in range(100_000)]
    current, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    print(cls.__name__, f"{current / 1e6:.1f} MB")
# 在 3.14 上實測約 P 9.6 MB vs PS 5.6 MB(含 list 本身與 int 物件)

ps = PS(1, 2)
# ps.z = 3   # AttributeError:沒有 __dict__,不能動態加屬性

__slots__ 的取捨:省記憶體、屬性存取稍快;但不能動態加屬性、預設不能被 weakref(要把 "__weakref__" 加進 slots)、多重繼承時多個有非空 slots 的父類別會衝突、子類別若沒宣告 __slots__ 又會長回 __dict__。適合「數百萬個小物件」的場景,例如在記憶體中保存大量事件或特徵。dataclass 可用 @dataclass(slots=True)(3.10+)。

Appier 情境

廣告平台的 bidder 或特徵服務常在 process 內保存大量物件(campaign 設定、user segment、模型特徵表)。Python 物件的 overhead 很大:一個小 int 就 28 bytes、一個空 dict 64 bytes。當常駐資料達數百萬筆時,選擇 __slots__、tuple/NamedTuple、array、NumPy 結構化陣列或乾脆放到 Redis / 共享記憶體,可以差到數倍記憶體,直接影響 container 能塞幾個 worker(K8s memory limit)與 GC 停頓。

GIL:是什麼、為什麼存在、怎麼繞過 高頻

Global Interpreter Lock 是 CPython 直譯器層級的一把 mutex:同一時間只有一個 OS thread 能執行 Python bytecode。多個 thread 仍然是真的 OS thread(由 kernel 排程),但要執行 bytecode 前必須先拿到 GIL。

為什麼要有 GIL

  • 保護 reference count:ob_refcnt++ 不是 atomic,若兩個 thread 同時改,計數錯了就會 use-after-free 或記憶體洩漏。若對每個物件加鎖或用 atomic 操作,單執行緒效能會明顯下降。
  • 保護直譯器內部狀態:記憶體配置器(pymalloc)、內建型別的內部結構(dict、list 的 resize)等。
  • 讓 C 擴充好寫:C 擴充作者不必處理細粒度鎖,這是 Python 早期生態(NumPy 等)快速成長的重要原因。
  • 歷史上(1990 年代)多核心機器罕見,一把大鎖是最簡單、單執行緒最快的設計。

GIL 何時釋放

  • 阻塞 I/O 時:socket.recv、file.read、time.sleep 等在進入系統呼叫前釋放 GIL,回來後再搶。
  • 時間片到期:3.2 起的「new GIL」,持有者執行超過 sys.getswitchinterval()(預設 0.005 秒 = 5ms),且有其他 thread 在等,就會被要求釋放。
  • C 擴充主動釋放:NumPy 的大型運算、hashlib 對大資料計算、zlib 壓縮等用 Py_BEGIN_ALLOW_THREADS 釋放 GIL,這段期間可以真正平行。
sequenceDiagram participant T1 as Thread 1 participant G as GIL participant T2 as Thread 2 T1->>G: acquire Note over T1: 執行 bytecode T2->>G: 等待(設定 drop request) Note over T1: 5ms 到期或進入 I/O T1->>G: release G->>T2: acquire Note over T2: 執行 bytecode Note over T1: 在 kernel 等 I/O(不需 GIL)

對 CPU-bound vs IO-bound 的影響

import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def cpu_task(_=None, n=5_000_000):   # 第一個參數吃掉 map 傳入的 index
    s = 0
    for i in range(n):
        s += i * i
    return s

def io_task(_=None):
    time.sleep(0.5)      # 模擬網路 I/O,sleep 期間釋放 GIL

def bench(executor_cls, fn, workers=4):
    t = time.perf_counter()
    with executor_cls(max_workers=workers) as ex:
        list(ex.map(fn, range(workers)))
    return time.perf_counter() - t

if __name__ == "__main__":        # ProcessPool 在 spawn 模式下必須有這個 guard
    print("CPU  threads :", bench(ThreadPoolExecutor, cpu_task))   # ≈ 單執行緒 × 4(甚至更慢)
    print("CPU  procs   :", bench(ProcessPoolExecutor, cpu_task))  # ≈ 單執行緒 × 1(4 核心平行)
    print("I/O  threads :", bench(ThreadPoolExecutor, io_task))    # ≈ 0.5 秒,I/O 期間可重疊

結論:IO-bound 用 threading 或 asyncio 很有效;CPU-bound 的純 Python 程式用 threading 無法加速,要用 multiprocessing、把運算下放到會釋放 GIL 的 C 擴充(NumPy),或用 free-threaded build。

常見陷阱:有 GIL ≠ thread-safe

GIL 只保證「單一 bytecode 指令」不被打斷,不保證你的程式邏輯是 atomic。counter += 1 是 load → add → store 多條指令,thread 切換可能發生在中間,照樣會遺失更新。list.append、dict[k] = v 這種單一 C 呼叫的操作在 CPython 上剛好是 atomic,但不要依賴它;共享狀態請用 threading.Lock 或 queue.Queue。

import threading

counter = 0
lock = threading.Lock()

def unsafe():
    global counter
    for _ in range(100_000):
        counter += 1           # 非 atomic:LOAD_GLOBAL, BINARY_OP, STORE_GLOBAL

def safe():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

threads = [threading.Thread(target=unsafe) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)   # 可能小於 800000(是否重現視版本與時機而定)

Free-threaded Python(PEP 703)現況

  • 3.13(2024/10):提供實驗性的 free-threaded build(執行檔通常叫 python3.13t),可在編譯時移除 GIL。用 sys._is_gil_enabled() 檢查。技術上改用 biased reference counting(擁有者 thread 用非 atomic 計數、其他 thread 用 atomic)、immortal objects(PEP 683,None、小整數等 refcount 不變)、per-object lock 保護 dict/list、改用 mimalloc 配置器。單執行緒效能有明顯折損,且 specializing interpreter 在該模式下被停用。
  • 3.14(2025/10):PEP 779 讓 free-threaded build 進入「officially supported」階段(不再標示 experimental),但仍不是預設;specialization 已在 free-threaded 模式恢復,單執行緒折損大幅縮小。
  • 生態:C 擴充必須宣告支援(Py_mod_gil)並重新編譯,否則載入時會重新啟用 GIL。NumPy、Cython 等主流套件陸續支援中。
面試官想聽到什麼

完整回答框架:(1) GIL 是什麼(一次只有一個 thread 跑 bytecode);(2) 為什麼存在(refcount 非 atomic、C 擴充簡單、單執行緒快);(3) 影響(CPU-bound threading 無效,IO-bound 有效,因為 I/O 時會釋放);(4) 繞過方法(multiprocessing、C 擴充釋放 GIL、asyncio 處理高併發 I/O、多 process 部署如 gunicorn workers);(5) 現況(PEP 703 free-threaded build 3.13 實驗、3.14 正式支援但非預設)。追問「有 GIL 為什麼還需要 Lock」→ 因為 GIL 只保護單一 bytecode,複合操作仍會 race。

內建資料結構內部:dict、set、list 與時間複雜度 高頻

dict:open addressing 的 hash table

CPython 的 dict 是 hash table,碰撞處理用 open addressing(不是 Java HashMap 的 separate chaining):計算 hash(key) 取低位元得到 slot,若已被佔用就依照「j = (5*j + 1 + perturb) mod 2^k,perturb >>= 5」的公式跳到下一個位置探測,逐步把 hash 的高位元也納入,避免聚集。

3.6 起是 compact dict(Raymond Hettinger 提出):拆成兩個陣列——

  • indices:稀疏的小整數陣列(依 table 大小用 int8/16/32/64),存 entries 的 index 或 EMPTY/DUMMY。
  • entries:密集陣列,依插入順序存 (hash, key, value)。
d = {"timmy": "red", "barry": "green", "guido": "blue"}

indices = [None, 1, None, None, None, 0, None, 2]     # 稀疏,每格只要 1 byte
entries = [[-9092791511155847987, 'timmy', 'red'],     # 密集、按插入順序
           [-8522787127447073495, 'barry', 'green'],
           [-6480567542315338377, 'guido', 'blue']]

好處:(1) 記憶體減少 20–25%(稀疏的部分只存小整數);(2) 迭代只需掃密集的 entries,更快;(3) 天然保持插入順序。3.6 是實作細節,3.7 起語言規範保證 dict 保持插入順序。刪除時 entries 留下空洞(DUMMY),resize 時壓縮。

當使用量達到容量的約 2/3 時 resize(重新配置更大的 table 並重新插入),所以 insert 是 amortized O(1)。最壞情況(大量碰撞)退化到 O(n)。

hash / eq 契約

  • 若 a == b 則必須 hash(a) == hash(b)(反之不必成立,hash 相同只是碰撞)。
  • 物件作為 key 期間,hash 值不可改變。
  • 查找流程:先比 hash 值(快速排除),再比 identity(is),最後才呼叫 __eq__。
  • 自訂 __eq__ 卻沒定義 __hash__ 時,Python 會把 __hash__ 設為 None,該類別變成 unhashable。
class Point:
    def __init__(self, x, y): self.x, self.y = x, y
    def __eq__(self, other):
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
    def __hash__(self):
        return hash((self.x, self.y))       # 用參與 __eq__ 的同一組欄位

print({Point(1, 2): "ok"}[Point(1, 2)])     # ok

# 違反契約的後果:key 放進去之後被改,就再也找不到
p = Point(1, 2); d = {p: "v"}
p.x = 100
print(p in d)   # False:用新的 hash 去找 slot,找不到;舊 slot 裡的物件又不相等
# 正確做法:key 物件應該不可變,例如 @dataclass(frozen=True) 或 NamedTuple

print(hash(1) == hash(1.0) == hash(True))   # True,且 1 == 1.0 == True,所以 {1: 'a', True: 'b'} 結果是 {1: 'b'}

為什麼 list 不能當 dict key?

因為 list 可變。若允許用內容計算 hash,list 被修改後 hash 就變了,dict 會到錯誤的 slot 找它(上面 Point 的例子)。若改用 identity 計算 hash,那 d[[1,2]] 永遠找不到「內容相同的另一個 list」,失去意義。所以 Python 選擇讓 list 的 __hash__ = None。需要時轉成 tuple(lst)(前提是元素都 hashable)或 frozenset。

set

set 也是 open addressing hash table,但只存 key(沒有 value)、不保證順序(沒有 compact 化,探測策略是先線性探測數格再跳躍,對 cache 較友善)。適合成員檢查(x in s O(1))與集合運算。frozenset 是不可變版本,可以當 dict key。

list:動態陣列(over-allocation)

list 底層是 PyObject* 指標的連續陣列(存的是指標,不是物件本身)。append 時若容量不足就 realloc 到更大的大小,成長因子約 1.125 倍加上常數(new = n + (n >> 3) + 6,再對齊到 4 的倍數),因此 append 是 amortized O(1)。

import sys
lst, prev = [], sys.getsizeof([])
for i in range(40):
    lst.append(i)
    if (size := sys.getsizeof(lst)) != prev:
        print(f"len={len(lst):2d} bytes={size}")
        prev = size
# 3.14 實測:len=1 → 88, 5 → 120, 9 → 184, 17 → 248, 25 → 312, 33 → 376
# 每次擴容不是每次 append,這就是 amortized O(1)

時間複雜度總表

結構操作平均備註
listlst[i]、lst[i] = x、lenO(1)
append、pop()(尾端)O(1) amortized
insert(0, x)、pop(0)、insert(i, x)O(n)要搬移後面元素;需要頭尾操作請用 deque
x in lst、index、remove、countO(n)線性掃描
slice lst[a:b]O(b−a)會複製(淺拷貝)
extend(k 個)O(k)
sort() / sorted()O(n log n)Timsort(3.11 起改用 Powersort 合併策略),穩定排序,已排序資料接近 O(n)
min、max、sum、複製O(n)
dictd[k]、d[k] = v、del d[k]、k in dO(1)最壞 O(n)(大量碰撞)
get、setdefault、popO(1)
迭代 keys/values/itemsO(n)views 是動態的、不複製
複製 d.copy()、d1 | d2O(n)
setadd、remove、discard、x in sO(1)
s & t(交集)O(min(len(s), len(t)))迭代較小的那個去查較大的
s | t、s - tO(len(s) + len(t))、O(len(s))
collections.dequeappend、appendleft、pop、popleftO(1)雙向鏈結的 block 陣列(每 block 64 個元素)
dq[i]O(n)靠近兩端快、中間慢
rotate(k)O(k)maxlen 可做固定長度滑動視窗
heapq(在 list 上的 min-heap)heappush、heappopO(log n)只有 min-heap;max-heap 存負值或 tuple(3.14 新增 heappush_max 等)
heapifyO(n)不是 O(n log n)!由下往上 sift down
h[0] 看最小值O(1)
nlargest(k, it)、nsmallestO(n log k)Top-K 問題標準解
from collections import deque, Counter, defaultdict, OrderedDict
import heapq

# 1) BFS 一定用 deque,list.pop(0) 是 O(n)
start = "A"
q = deque([start]); seen = {start}

# 2) Top-K:100 萬個廣告的 CTR 取前 10 名,O(n log k)
scores = {"ad1": 0.031, "ad2": 0.054, "ad3": 0.012, "ad4": 0.047}
print(heapq.nlargest(2, scores.items(), key=lambda kv: kv[1]))   # [('ad2', 0.054), ('ad4', 0.047)]

# 3) Counter:Wordle 判題(處理重複字母的經典寫法)
def wordle(answer: str, guess: str) -> str:
    res = ["B"] * len(guess)                 # B=灰, Y=黃, G=綠
    remain = Counter()
    for i, (a, g) in enumerate(zip(answer, guess)):
        if a == g:
            res[i] = "G"
        else:
            remain[a] += 1                   # 只有沒對上綠色的字母才能拿來配黃色
    for i, g in enumerate(guess):
        if res[i] != "G" and remain[g] > 0:
            res[i] = "Y"
            remain[g] -= 1
    return "".join(res)
print(wordle("apple", "paper"))  # YYGYB

# 4) LRU cache 用 OrderedDict(move_to_end + popitem(last=False) 都是 O(1))
class LRU:
    def __init__(self, cap): self.cap, self.d = cap, OrderedDict()
    def get(self, k):
        if k not in self.d: return None
        self.d.move_to_end(k); return self.d[k]
    def put(self, k, v):
        self.d[k] = v; self.d.move_to_end(k)
        if len(self.d) > self.cap: self.d.popitem(last=False)

Appier 真題:合併兩個 List[dict] 中相同 id 的資料

from collections import defaultdict
from typing import Any

def merge_by_id(*sources: list[dict[str, Any]], key: str = "id") -> list[dict[str, Any]]:
    """O(N) 合併多個來源的 user attributes;後面來源覆蓋前面,保留第一次出現的順序。"""
    merged: dict[Any, dict[str, Any]] = {}       # dict 保持插入順序(3.7+ 保證)
    for src in sources:
        for row in src:
            merged.setdefault(row[key], {}).update(row)
    return list(merged.values())

a = [{"id": 1, "name": "Amy"}, {"id": 2, "name": "Bob"}]
b = [{"id": 2, "age": 30}, {"id": 3, "name": "Cat", "age": 22}]
print(merge_by_id(a, b))
# [{'id': 1, 'name': 'Amy'}, {'id': 2, 'name': 'Bob', 'age': 30}, {'id': 3, 'name': 'Cat', 'age': 22}]
面試官想聽到什麼(含追問)

先講暴力解 O(N×M)(雙迴圈),再改用 dict 索引成 O(N+M)。追問點:(1) 衝突策略:同欄位不同值怎麼辦?後者覆蓋、保留非 null、依時間戳、或收集成 list;(2) 巢狀 dict 要不要 deep merge;(3) 資料大到記憶體放不下(上億筆):兩份都依 id 排序後做 sort-merge join(外部排序,逐行串流),或依 hash(id) % N 分片成 N 個小檔各自合併,或直接交給 Spark / SQL 的 join(見第 12 章);(4) 用 generator 串流處理避免一次讀進記憶體。

常見陷阱:每一個都可能是面試題 高頻

1. Mutable default arguments

def add_tag(tag, tags=[]):        # 預設值在 def 執行時「只求值一次」
    tags.append(tag)
    return tags

print(add_tag("a"))   # ['a']
print(add_tag("b"))   # ['a', 'b']  ← 同一個 list 被所有呼叫共用
print(add_tag.__defaults__)        # (['a', 'b'],) 預設值就存在函式物件上

def add_tag_fixed(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

為什麼會這樣:def 是一個在執行期執行的陳述句,它建立函式物件時把預設值算好存進 __defaults__。之後每次呼叫若沒傳參數,就用同一個物件。這個設計讓預設值計算只發生一次(效能),也讓「用預設參數當函式層級快取」成為可能(但不推薦)。

2. Late binding closures(迴圈中的 lambda)

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])        # [2, 2, 2],不是 [0, 1, 2]

# 原因:closure 捕捉的是「變數 i」(cell),不是當下的值;呼叫時才查 i,此時 i 已經是 2
funcs = [lambda i=i: i for i in range(3)]          # 解法 1:用預設參數在定義時綁值
print([f() for f in funcs])        # [0, 1, 2]

from functools import partial
funcs = [partial(lambda x: x, i) for i in range(3)] # 解法 2:partial
print([f() for f in funcs])        # [0, 1, 2]

同樣的問題出現在 for 迴圈裡註冊 callback、建立 thread(Thread(target=lambda: work(i)))時。Go 在 1.22 之前也有一模一樣的問題(goroutine 捕捉迴圈變數),1.22 起改成每次迭代一個新變數。

3. is vs ==

x = 1000
print(x is 1000)     # SyntaxWarning: "is" with 'int' literal;結果依實作而定
print([] is [])      # False:兩個不同物件
print(None == None, None is None)   # 都 True,但請寫 is None

4. 迭代中修改容器

nums = [1, 2, 2, 3, 4]
for n in nums:
    if n % 2 == 0:
        nums.remove(n)       # 刪除後元素左移,iterator 的 index 照常 +1,會跳過元素
print(nums)                  # [1, 2, 3]  ← 第二個 2 被跳過了

nums = [1, 2, 2, 3, 4]
nums = [n for n in nums if n % 2]       # 正確:建立新 list
print(nums)                  # [1, 3]

d = {"a": 1, "b": 2}
# for k in d: del d[k]       # RuntimeError: dictionary changed size during iteration
for k in list(d):            # 先複製 keys
    if d[k] > 1: del d[k]

5. 浮點數

print(0.1 + 0.2)                 # 0.30000000000000004(IEEE 754 二進位無法精確表示 0.1)
print(0.1 + 0.2 == 0.3)          # False
import math
print(math.isclose(0.1 + 0.2, 0.3))      # True:比較浮點數用容差

from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))   # 0.3:金額計算用 Decimal(注意要傳字串)
print(round(2.5), round(3.5))    # 2 4:banker's rounding(四捨六入五成雙)
print(int(-3.7), -7 // 2, -7 % 2) # -3 -4 1:int() 往 0 截斷;// 往負無限大取 floor
Appier 情境

廣告計費(CPM、CPC、預算扣款)牽涉金額時,常見做法是以最小單位的整數(例如 micro-dollars,1 USD = 1,000,000)儲存與計算,避免浮點誤差在數十億次曝光後累積成對帳差異。報表顯示時才轉換。

6. 淺拷貝 vs 深拷貝

import copy
grid = [[0] * 3] * 3          # 陷阱:外層 list 裡是「同一個」內層 list 的 3 個參考
grid[0][0] = 1
print(grid)                   # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
grid = [[0] * 3 for _ in range(3)]   # 正確

orig = {"tags": ["a"], "n": 1}
shallow = orig.copy()         # 或 dict(orig)、copy.copy(orig)、list 用 lst[:]
deep = copy.deepcopy(orig)    # 遞迴複製(會處理循環參考,透過 memo dict)
orig["tags"].append("b")
print(shallow["tags"], deep["tags"])   # ['a', 'b'] ['a']

淺拷貝只複製最外層容器,內部元素仍是同一批物件;深拷貝遞迴複製,成本高(且對大型結構很慢)。在 API 服務中,若把全域設定 dict 回傳給呼叫端並被修改,就會汙染所有後續請求——回傳 MappingProxyType(唯讀 view)或 frozen dataclass 更安全。

7. 其他經典

  • UnboundLocalError:函式內對變數賦值,編譯器就把它當 local;賦值前讀取會炸。要改外層變數用 global / nonlocal。
  • class 屬性被所有 instance 共用:class A: items = [],所有 instance 的 self.items.append 都改到同一個 list。
  • except: 裸捕捉:會連 KeyboardInterrupt、SystemExit 一起吞掉。
  • 字串累加:迴圈中 s += x 最壞 O(n²)(CPython 有時能就地優化但不保證),用 "".join(parts)。
  • generator 只能迭代一次:第二次迭代是空的,不會報錯。
  • circular import:模組互相 import 時,取用到尚未執行完的模組屬性會 ImportError/AttributeError。

Iterator、Generator 與 itertools 進階

迭代協定

  • Iterable:有 __iter__(),回傳 iterator(list、dict、str、file)。
  • Iterator:有 __next__()(沒東西時 raise StopIteration)且 __iter__() 回傳自己。
  • for x in obj 等價於:it = iter(obj),反覆 next(it) 直到 StopIteration。
class Countdown:                 # iterable:每次 iter() 都回傳新的 iterator,可重複迭代
    def __init__(self, n): self.n = n
    def __iter__(self):
        n = self.n
        while n > 0:
            yield n              # __iter__ 寫成 generator 是最簡潔的做法
            n -= 1

c = Countdown(3)
print(list(c), list(c))          # [3, 2, 1] [3, 2, 1]

Generator 與 yield

含 yield 的函式呼叫時不執行函式本體,而是回傳一個 generator 物件。每次 next() 執行到下一個 yield 暫停,frame(local 變數、指令位置)被保存在 heap 上,下次從暫停處繼續。這讓 generator 成為「惰性求值」與「O(1) 記憶體處理串流」的利器,也是 asyncio coroutine 的歷史基礎。

import json

def read_events(path):
    """逐行讀取上 GB 的 JSON Lines,記憶體只用一行的量"""
    with open(path) as f:
        for line in f:
            yield json.loads(line)

def only_clicks(events):
    return (e for e in events if e.get("type") == "click")   # generator expression

def batched_ids(events, size):
    batch = []
    for e in events:
        batch.append(e["user_id"])
        if len(batch) == size:
            yield batch
            batch = []
    if batch:
        yield batch

# pipeline:每一層都是惰性的,資料一筆一筆流過
# for chunk in batched_ids(only_clicks(read_events("events.jsonl")), 500):
#     bulk_write(chunk)

# list comprehension vs generator expression
import sys
print(sys.getsizeof([x for x in range(1_000_000)]))   # 約 8 MB
print(sys.getsizeof((x for x in range(1_000_000))))   # 約 200 bytes,與資料量無關
print(sum(x * x for x in range(10)))                  # 當唯一參數時可省略括號

yield from、send() 與 return 值

def inner():
    received = yield "first"          # yield 是運算式,可以接收 send 進來的值
    print("inner got", received)
    yield "second"
    return "inner-done"               # return 值放在 StopIteration.value

def outer():
    result = yield from inner()       # 委派:把 next/send/throw 直接轉給 inner,並取得其 return 值
    print("outer got", result)
    yield "third"

g = outer()
print(next(g))        # first
print(g.send("hi"))   # inner got hi → second
print(next(g))        # outer got inner-done → third

yield from 除了扁平化巢狀 generator,還正確轉發 send()、throw()、close()。Python 3.4 的 asyncio 就是用 yield from 實作 coroutine,3.5 才引入 async/await 專用語法(概念上 await ≈ yield from)。

itertools 必會清單

import itertools as it

print(list(it.islice(it.count(10, 5), 3)))              # [10, 15, 20]:無限序列 + 切片
print(list(it.chain([1, 2], (3,), {4})))                # [1, 2, 3, 4]
print(list(it.chain.from_iterable([[1], [2, 3]])))      # 攤平一層
print(list(it.accumulate([1, 2, 3, 4])))                # [1, 3, 6, 10] prefix sum
print(list(it.product("ab", repeat=2)))                 # 笛卡兒積
print(list(it.permutations([1, 2, 3], 2)))              # 排列
print(list(it.combinations([1, 2, 3], 2)))              # 組合
print(list(it.pairwise([1, 2, 4, 7])))                  # [(1,2),(2,4),(4,7)](3.10+)
print(list(it.batched(range(7), 3)))                    # [(0,1,2),(3,4,5),(6,)](3.12+)

# groupby 只合併「相鄰」相同 key,通常要先排序
logs = [("u1", "click"), ("u1", "view"), ("u2", "view"), ("u1", "buy")]
for user, grp in it.groupby(sorted(logs), key=lambda r: r[0]):
    print(user, [a for _, a in grp])
# u1 ['buy', 'click', 'view']
# u2 ['view']

a, b = it.tee(iter(range(3)))   # 一個 iterator 分成兩個(內部會緩衝)
常見陷阱

(1) generator 被耗盡後再迭代會靜默回傳空;(2) 在 generator 內丟出的 StopIteration 3.7 起會被轉成 RuntimeError(PEP 479);(3) groupby 沒排序會得到重複的 group;(4) generator 若持有檔案或連線,提早 break 時要靠 close() 觸發 finally/with 清理,CPython 在 refcount 歸零時會自動 close,但最好明確處理。

Decorator 與 Context Manager 高頻

Decorator 本質:接收函式、回傳函式的 callable

@deco 放在 def f 上面等價於 f = deco(f),在定義時(import 時)執行一次。能成立是因為函式是一級物件(first-class)加上 closure。

import functools, time, logging

def timed(func):
    @functools.wraps(func)            # 複製 __name__、__doc__、__qualname__、__wrapped__ 等
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            ms = (time.perf_counter() - start) * 1000
            logging.info("%s took %.2f ms", func.__qualname__, ms)
    return wrapper

@timed
def fetch_user(uid: int) -> dict:
    """從 DB 讀使用者"""
    return {"id": uid}

print(fetch_user.__name__, fetch_user.__doc__)   # fetch_user 從 DB 讀使用者(沒 wraps 會變成 wrapper None)

為什麼一定要 functools.wraps:沒有它,被裝飾的函式名稱、docstring、型別註解都變成 wrapper 的,導致 log、除錯、FastAPI 依 signature 產生 OpenAPI、pytest fixture、inspect.signature 都出錯。

帶參數的 decorator:三層函式

import functools, random, time

def retry(times=3, backoff=0.1, exceptions=(ConnectionError, TimeoutError)):
    """@retry(times=5) → retry(times=5) 先回傳真正的 decorator,再套到函式上"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions:
                    if attempt == times:
                        raise                                  # 保留原始 traceback
                    sleep = backoff * 2 ** (attempt - 1)
                    time.sleep(sleep + random.uniform(0, sleep))  # exponential backoff + jitter
        return wrapper
    return decorator

@retry(times=5, backoff=0.05)
def call_partner_api():
    if random.random() < 0.5:
        raise ConnectionError("flaky")
    return "ok"

也可以用 class 實作 decorator(實作 __call__),適合需要保存狀態(例如計數、rate limit bucket)時。多個 decorator 疊加時,離函式最近的先套用:@a @b def f 等於 f = a(b(f))。

functools.lru_cache / cache

from functools import lru_cache, cache

@lru_cache(maxsize=1024)        # 參數必須 hashable;maxsize=None 等於 @cache(無上限)
def fib(n: int) -> int:
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(200))
print(fib.cache_info())          # CacheInfo(hits=198, misses=201, maxsize=1024, currsize=201)
fib.cache_clear()
lru_cache 的陷阱

(1) 裝在 instance method 上,self 會成為 key 的一部分,cache 持有 self 的強參考 → instance 永遠不會被回收(記憶體洩漏);改用 functools.cached_property 或模組層級函式。(2) 回傳可變物件時,呼叫端修改會汙染 cache。(3) 沒有 TTL,資料會過期的場景要用 cachetools.TTLCache 或 Redis。(4) 是 per-process 的,多個 gunicorn worker 各有一份。(5) thread-safe(內部有鎖保護結構),但同一個 key 在 miss 時可能被多個 thread 同時計算。

Context Manager:__enter__ / __exit__

with 保證「取得資源 → 使用 → 不論是否例外都釋放」。__exit__(exc_type, exc, tb) 回傳 truthy 值會吞掉例外(多數情況應回傳 None/False)。

import time, threading
from contextlib import contextmanager, suppress, ExitStack

class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self                      # as 後面拿到的東西
    def __exit__(self, exc_type, exc, tb):
        self.elapsed = time.perf_counter() - self.start
        return False                     # 不吞例外

with Timer() as t:
    sum(range(1_000_000))
print(f"{t.elapsed * 1000:.1f} ms")

@contextmanager
def acquire_with_timeout(lock: threading.Lock, timeout: float):
    ok = lock.acquire(timeout=timeout)
    if not ok:
        raise TimeoutError("lock busy")
    try:
        yield                            # with 區塊在這裡執行;區塊中的例外會在此處被 raise
    finally:
        lock.release()                   # 一定要 try/finally,否則例外時不會釋放

with suppress(FileNotFoundError):        # 取代 try/except/pass
    open("/not/exist")

# ExitStack:動態數量的資源(例如開 N 個檔案),任何一個失敗都會正確關閉已開啟的
paths = []
with ExitStack() as stack:
    files = [stack.enter_context(open(p)) for p in paths]

常見應用:DB transaction(成功 commit、例外 rollback)、鎖、暫時切換設定、計時、OpenTelemetry span。async 版本是 __aenter__/__aexit__ 搭配 async with,以及 contextlib.asynccontextmanager。

物件導向進階:descriptor、MRO、metaclass、dataclass 與 typing 深入

屬性查找順序與 descriptor

Descriptor 是實作了 __get__/__set__/__delete__ 的物件,放在 class 屬性上時會攔截 instance 的屬性存取。property、classmethod、staticmethod、函式本身(function 實作 __get__ 才變成 bound method)、__slots__ 都是 descriptor。

obj.attr 的查找順序(object.__getattribute__):

  1. 沿 MRO 在 type(obj) 找 attr;若是 data descriptor(有 __set__ 或 __delete__)→ 呼叫其 __get__。
  2. 否則看 obj.__dict__ 有沒有 → 有就回傳。
  3. 否則若第 1 步找到的是 non-data descriptor(只有 __get__,例如函式)→ 呼叫 __get__;是普通值就直接回傳。
  4. 都沒有 → 呼叫 __getattr__(若有定義)→ 否則 AttributeError。
class Positive:
    """可重用的驗證 descriptor"""
    def __set_name__(self, owner, name):          # 3.6+:自動得知被指派到的屬性名
        self.private = "_" + name
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self                           # 從 class 存取時回傳 descriptor 本身
        return getattr(obj, self.private)
    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.private[1:]} must be > 0")
        setattr(obj, self.private, value)

class Bid:
    price = Positive()
    budget = Positive()
    def __init__(self, price, budget):
        self.price, self.budget = price, budget  # 會經過 Positive.__set__

    @property
    def max_impressions(self):                   # property 是 data descriptor 的語法糖
        return int(self.budget / self.price * 1000)

b = Bid(price=2.5, budget=100)
print(b.max_impressions)   # 40000
# Bid(price=-1, budget=1) → ValueError: price must be > 0

functools.cached_property 是 non-data descriptor:第一次計算後把結果寫入 obj.__dict__,之後依查找順序第 2 步直接命中 instance dict,不再呼叫 descriptor。

MRO、C3 linearization 與 super()

多重繼承時,Python 用 C3 linearization 計算 Method Resolution Order:保證 (1) 子類別在父類別前面;(2) 保留每個 class 宣告的父類別順序(local precedence);(3) 單調性。無法滿足時直接 TypeError。

class Base:
    def save(self): print("Base.save")

class CacheMixin(Base):
    def save(self):
        print("CacheMixin.save: invalidate cache")
        super().save()

class AuditMixin(Base):
    def save(self):
        print("AuditMixin.save: write audit log")
        super().save()

class Campaign(CacheMixin, AuditMixin):
    def save(self):
        print("Campaign.save")
        super().save()

print([c.__name__ for c in Campaign.__mro__])
# ['Campaign', 'CacheMixin', 'AuditMixin', 'Base', 'object']
Campaign().save()
# Campaign.save → CacheMixin.save → AuditMixin.save → Base.save

關鍵:super() 不是「呼叫父類別」,而是「呼叫 MRO 中下一個 class」,而這個 MRO 是依 type(self) 決定的。所以 CacheMixin 裡的 super() 在 Campaign instance 上會呼叫到兄弟類別 AuditMixin。這就是 cooperative multiple inheritance:每個 class 都呼叫 super(),菱形繼承中 Base.save 只會被呼叫一次。無參數 super() 靠編譯器注入的 __class__ cell 與第一個參數實作。

Metaclass 簡介

class 也是物件,建立 class 的「class」就是 metaclass(預設 type)。class A: ... 大致等於 A = type("A", (bases), namespace)。Metaclass 可以在 class 被建立時介入:註冊、驗證、修改屬性。Django ORM 的 Model、SQLAlchemy declarative、舊版 pydantic 都用過 metaclass。

class RegistryMeta(type):
    registry: dict[str, type] = {}
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        if bases:                              # 不註冊抽象基底
            mcls.registry[name.lower()] = cls
        return cls

class Handler(metaclass=RegistryMeta): ...
class ClickHandler(Handler): ...
print(RegistryMeta.registry)    # {'clickhandler': <class '__main__.ClickHandler'>}

# 大多數情況用更簡單的 __init_subclass__(3.6+)就夠了
class Plugin:
    plugins = {}
    def __init_subclass__(cls, /, key: str, **kw):
        super().__init_subclass__(**kw)
        Plugin.plugins[key] = cls
class S3Sink(Plugin, key="s3"): ...
print(Plugin.plugins)           # {'s3': <class '__main__.S3Sink'>}
實戰提示

Tim Peters 名言:「Metaclasses are deeper magic than 99% of users should ever worry about.」面試時能說出「class 是 type 的 instance、metaclass 控制 class 的建立、多數需求用 __init_subclass__ 或 class decorator 即可」就足夠,展現取捨判斷比炫技重要。

dataclass

from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone

@dataclass(frozen=True, slots=True)            # frozen → 不可變且自動產生 __hash__;slots → 省記憶體(3.10+)
class BidRequest:
    request_id: str
    user_id: str
    floor_price: float
    tags: tuple[str, ...] = ()                 # 不可變預設值 OK
    ts: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

@dataclass(order=True)
class Job:
    priority: int
    name: str = field(compare=False)
    payload: list = field(default_factory=list, repr=False)   # 可變預設值必須用 default_factory

r = BidRequest("r1", "u9", 0.8)
print(r, asdict(r)["floor_price"])
print(sorted([Job(3, "c"), Job(1, "a")])[0].name)   # a
dataclassNamedTuplepydantic BaseModelTypedDict
執行期驗證型別否否是(並做轉型)否(只是 dict)
可變預設可變,可 frozen不可變預設可變,可設定 frozen可變
效能 / 記憶體好(slots 更好)好(tuple)驗證有成本(v2 核心用 Rust,快很多)就是 dict
典型用途內部 domain 物件輕量不可變紀錄API 邊界(request/response、設定檔)描述 JSON 形狀給型別檢查器

typing:Protocol、TypedDict、Generic 與 mypy

型別註解在執行期不會被強制(只存在 __annotations__),由 mypy / pyright 靜態檢查,或由 pydantic / FastAPI 在執行期讀取使用。

from typing import Protocol, TypedDict, NotRequired, TypeVar, Generic, Iterable, runtime_checkable

# 1) Protocol:structural typing(duck typing 的靜態版),不需繼承
@runtime_checkable
class Cache(Protocol):
    def get(self, key: str) -> bytes | None: ...
    def set(self, key: str, value: bytes, ttl: int) -> None: ...

class DictCache:                      # 沒有繼承 Cache,但結構相符就通過型別檢查
    def __init__(self): self._d: dict[str, bytes] = {}
    def get(self, key: str) -> bytes | None: return self._d.get(key)
    def set(self, key: str, value: bytes, ttl: int) -> None: self._d[key] = value

def warm_up(cache: Cache, items: Iterable[tuple[str, bytes]]) -> None:
    for k, v in items:
        cache.set(k, v, ttl=60)

warm_up(DictCache(), [("a", b"1")])
print(isinstance(DictCache(), Cache))   # True(runtime_checkable 只檢查方法是否存在)

# 2) TypedDict:描述 JSON / dict 的形狀
class Event(TypedDict):
    event_id: str
    user_id: str
    value: float
    campaign_id: NotRequired[str]

def handle(e: Event) -> float:
    return e["value"]          # mypy 會檢查 key 名稱與型別

# 3) Generic(3.12+ 可寫成 class Box[T]: ...,PEP 695)
T = TypeVar("T")
class Batch(Generic[T]):
    def __init__(self) -> None: self.items: list[T] = []
    def add(self, item: T) -> None: self.items.append(item)

b: Batch[int] = Batch()
b.add(1)
# b.add("x")   # mypy: Argument 1 to "add" of "Batch" has incompatible type "str"; expected "int"
uv pip install mypy
mypy --strict app/          # CI 中逐步導入:先對新模組 strict,舊模組用 per-module 設定放寬
面試官想聽到什麼

「Python 是動態型別,為什麼還要 type hints?」→ 大型團隊/長期維護的服務中,型別是可被工具驗證的文件:重構安全、IDE 補全、在 CI 抓到 None 誤用等 bug;FastAPI/pydantic 更直接用型別產生驗證與 OpenAPI 文件。Protocol 讓你保留 duck typing 的彈性又有靜態檢查(例如依賴注入時 mock 一個 Cache)。

asyncio 與並行模型比較 高頻

asyncio 是單執行緒、協作式(cooperative)多工:一個 event loop 管理大量 coroutine,coroutine 在 await 一個尚未完成的 I/O 時主動讓出控制權,loop 再用 OS 的 I/O multiplexing(Linux 上是 epoll)找出已就緒的 socket,恢復對應的 coroutine。因為切換只發生在 await 點,不需要 thread 切換成本,也幾乎不需要鎖。

flowchart TB L["Event loop(單一 thread)"] --> R["ready queue:可立即執行的 callbacks / tasks"] R --> C1["Task A 執行到 await sock.recv()"] C1 -->|"I/O 未就緒,註冊 fd 並讓出"| E["epoll_wait(timeout = 最近的 timer)"] E -->|"fd 就緒 → future.set_result"| R T["timer heap(asyncio.sleep、timeout)"] --> E

核心概念

coroutine function
async def f();呼叫 f() 只會產生 coroutine 物件,不會執行(忘記 await 會得到 "coroutine was never awaited" 警告)。
await
暫停目前 coroutine,直到 awaitable(coroutine、Task、Future)完成。只能在 async def 裡用。
Task
把 coroutine 包起來排進 event loop 並發執行(asyncio.create_task)。要保留 Task 的參考,否則可能被 GC 回收。
Future
低階的「未來結果」占位物件,Task 是它的子類別。
event loop
排程器;asyncio.run(main()) 建立 loop、執行到 main 完成、關閉 loop。
import asyncio, time

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)          # 模擬網路 I/O;讓出控制權
    return f"{name} done"

async def main():
    t = time.perf_counter()
    # 1) 循序 await:總共 1 + 2 = 3 秒
    # await fetch("a", 1); await fetch("b", 2)

    # 2) gather:並發,總共約 2 秒(最慢的那個)
    results = await asyncio.gather(fetch("a", 1), fetch("b", 2), return_exceptions=True)
    print(results, f"{time.perf_counter() - t:.1f}s")

    # 3) TaskGroup(3.11+,structured concurrency):任一失敗會取消其他並一起拋出 ExceptionGroup
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(fetch("c", 0.5))
        t2 = tg.create_task(fetch("d", 0.3))
    print(t1.result(), t2.result())

    # 4) timeout:3.11+ asyncio.timeout;舊版 asyncio.wait_for
    try:
        async with asyncio.timeout(0.1):
            await fetch("slow", 1)
    except TimeoutError:
        print("timed out → 回傳預設值 / no-bid")

    # 5) 限制並發數:Semaphore(例如同時最多 10 個對外連線)
    sem = asyncio.Semaphore(10)
    async def limited(i):
        async with sem:
            return await fetch(str(i), 0.01)
    await asyncio.gather(*(limited(i) for i in range(100)))

asyncio.run(main())

千萬不要在 async 中呼叫 blocking code

event loop 只有一個 thread。若某個 coroutine 呼叫 time.sleep(1)、requests.get()、同步 DB driver、或做 200ms 的 CPU 運算,整個 loop 都卡住,所有其他請求的延遲一起暴增。這是 async 服務 p99 爆掉最常見的原因。

import asyncio, time, hashlib
from concurrent.futures import ProcessPoolExecutor

def blocking_io():                     # 例如舊的同步 SDK
    time.sleep(0.5); return "io"

def cpu_heavy(data: bytes) -> str:     # 例如大量 hash / 特徵計算
    for _ in range(200_000):
        data = hashlib.sha256(data).digest()
    return data.hex()[:8]

async def main():
    loop = asyncio.get_running_loop()
    # IO-bound 的同步函式 → 丟到 thread pool(3.9+ 簡寫 asyncio.to_thread)
    r1 = await asyncio.to_thread(blocking_io)
    # CPU-bound → 丟到 process pool,避開 GIL
    with ProcessPoolExecutor() as pool:
        r2 = await loop.run_in_executor(pool, cpu_heavy, b"seed")
    print(r1, r2)

if __name__ == "__main__":
    asyncio.run(main())
實戰提示:抓出卡住 loop 的程式

開啟 debug 模式:PYTHONASYNCIODEBUG=1 或 asyncio.run(main(), debug=True),任何一步執行超過 loop.slow_callback_duration(預設 0.1 秒)都會印出警告與位置。生產環境可用 py-spy dump 看 loop thread 卡在哪(見第 8 章)。選擇 async 生態的 library:httpx/aiohttp、asyncpg、redis.asyncio、aiokafka。

async context manager 與 async iterator

import asyncio
from contextlib import asynccontextmanager

@asynccontextmanager
async def db_conn(pool):
    conn = await pool.acquire()
    try:
        yield conn
    finally:
        await pool.release(conn)

async def stream_events(n: int):      # async generator:可在 yield 之間 await
    for i in range(n):
        await asyncio.sleep(0.01)      # 例如從 Kafka 拉下一批
        yield {"id": i}

async def consume():
    async for ev in stream_events(3):
        print(ev)
    squares = [e["id"] ** 2 async for e in stream_events(3)]   # async comprehension
    print(squares)

asyncio.run(consume())

取消(cancellation)語意

task.cancel() 會在 task 下一個 await 點丟出 asyncio.CancelledError。3.8 起它繼承 BaseException,所以 except Exception 不會誤吞;若你捕捉它做清理,清理完要重新 raise。timeout 就是靠取消實作的。客戶端斷線時,ASGI server 也可能取消對應的 request handler。

threading vs multiprocessing vs asyncio vs concurrent.futures

threadingmultiprocessingasyncioconcurrent.futures
執行單位OS threadOS processcoroutine(單 thread)統一的高階介面:ThreadPoolExecutor / ProcessPoolExecutor
受 GIL 限制是(CPU 無法平行)否(每個 process 有自己的 GIL)是(本來就單 thread)依底層而定
適合IO-bound、包裝同步 libraryCPU-bound大量並發 I/O(數千~數萬連線)簡單的「丟一批工作進 pool 等結果」
切換方式preemptive(OS + GIL 每 5ms)OS 排程cooperative(只在 await)
記憶體 / 建立成本每 thread 預設 stack 數 MB(虛擬)最高(獨立直譯器與記憶體)每個 coroutine 約 KB 級
資料共享共享記憶體,需 Lock需序列化(pickle)透過 Queue/Pipe,或 shared_memory共享,但 await 之間的程式碼天然 atomic
主要風險race condition、deadlockpickle 成本、fork 與 thread 混用的問題不小心呼叫 blocking code;「async 傳染」整個呼叫鏈
from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib.request

URLS = ["https://example.com", "https://example.org"]

def get(url):
    with urllib.request.urlopen(url, timeout=5) as r:
        return url, r.status

with ThreadPoolExecutor(max_workers=8) as ex:
    futures = {ex.submit(get, u): u for u in URLS}
    for fut in as_completed(futures):          # 誰先完成先處理
        try:
            print(fut.result())
        except Exception as e:
            print(futures[fut], "failed:", e)
multiprocessing 的坑

(1) Linux 傳統預設 start method 是 fork,若 fork 時其他 thread 正持有鎖(例如 logging 的鎖),子行程會繼承一把永遠不會被釋放的鎖 → deadlock;3.14 起 Linux 預設改為 forkserver。(2) spawn(macOS/Windows 預設)會重新 import 主模組,所以要有 if __name__ == "__main__":。(3) 傳給 worker 的參數與回傳值都要 pickle,大資料的序列化成本可能比計算還貴。

面試官想聽到什麼

「asyncio 跟 threading 怎麼選?」→ 都適合 IO-bound;asyncio 在超大量連線時記憶體與切換成本更低、沒有 data race(切換點明確),但需要整條呼叫鏈都是 async 的 library;threading 能直接包裝現有同步程式碼。CPU-bound 兩者都不行,用 multiprocessing 或原生擴充。實務上 web 服務常是「多 process(gunicorn workers,吃滿多核)× 每個 process 內 asyncio(處理大量並發 I/O)」的組合。

例外處理最佳實踐與打包環境 進階

例外處理

import logging, json
log = logging.getLogger(__name__)

class AppError(Exception):
    """應用層例外的基底,方便上層統一處理"""
class NotFoundError(AppError): ...
class UpstreamError(AppError): ...

def load_campaign(cid: str, raw: str) -> dict:
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as e:          # 1) 捕捉具體的例外,不要裸 except
        raise AppError(f"bad payload for {cid}") from e   # 2) 用 from 保留因果鏈(__cause__)
    else:                                      # 3) 沒有例外才執行;讓 try 區塊盡量小
        if "id" not in data:
            raise NotFoundError(cid)
        return data
    finally:                                   # 4) 一定會執行:清理資源
        log.debug("load_campaign %s finished", cid)

try:
    load_campaign("c1", "{bad json")
except AppError:
    log.exception("failed")                    # 5) log.exception 會自動附上 traceback

# 6) 3.11+:ExceptionGroup 與 except*,處理並發任務的多個錯誤
try:
    raise ExceptionGroup("batch", [ValueError("a"), TypeError("b")])
except* ValueError as eg:
    print("values:", eg.exceptions)
except* TypeError as eg:
    print("types:", eg.exceptions)
  • EAFP vs LBYL:Python 慣用「先做再處理例外」(Easier to Ask Forgiveness than Permission),例如直接 d[k] 再捕捉 KeyError,避免 check-then-act 的 race(檔案存在檢查後被刪除)。但例外路徑很頻繁時,dict.get 更快更清楚。
  • 不要用例外控制正常流程中的高頻分支——拋出例外在 CPython 有成本(3.11 起 try 區塊本身是「zero-cost」,只有真的拋出才付費)。
  • 在服務邊界(API handler、worker 主迴圈)統一捕捉,轉成正確的 HTTP 狀態碼與結構化 log;內部不要到處 catch 再吞掉。
  • 重試只針對 transient 錯誤(逾時、連線重置、HTTP 503),且要有上限、backoff 與 jitter;非冪等操作要搭配 idempotency key。

打包與環境:uv、venv、pyproject.toml

現代 Python 專案以 pyproject.toml(PEP 517/518/621)為唯一設定來源,取代 setup.py + requirements.txt + setup.cfg 的組合。uv(Astral 以 Rust 撰寫)整合了 pip、venv、pip-tools、pyenv、pipx 的功能,解析與安裝速度比 pip 快一到兩個數量級。

uv venv                       # 建立 .venv
source .venv/bin/activate
uv pip install fastapi uvicorn    # pip 相容介面

# 或以專案為中心的工作流
uv init bidder && cd bidder
uv add fastapi "uvicorn[standard]" orjson
uv add --dev pytest mypy ruff
uv lock                       # 產生 uv.lock(跨平台可重現的鎖定檔)
uv sync --frozen              # CI / Docker 中依 lock 安裝,鎖定檔與 pyproject 不一致就失敗
uv run pytest
uv python install 3.13        # 管理 Python 版本本身
[project]
name = "bidder"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.115",
    "uvicorn[standard]>=0.30",
    "orjson>=3.10",
]

[dependency-groups]
dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6"]

[tool.ruff]
line-length = 100

[tool.mypy]
strict = true
實戰提示

為什麼一定要 virtual environment:隔離不同專案的依賴版本、不汙染系統 Python(很多 Linux 發行版的工具依賴系統 Python,PEP 668 甚至直接禁止對系統 Python 做 pip install)。容器裡也建議用 venv + lock 檔,並用 multi-stage build 讓最終 image 不含編譯工具(見第 15 章)。

Python Web 生態:WSGI、ASGI、worker 模型與框架 高頻

WSGI vs ASGI

# WSGI(PEP 3333):同步,一個請求佔用一個 worker(thread/process)直到回應完成
def wsgi_app(environ, start_response):
    start_response("200 OK", [("Content-Type", "text/plain")])
    return [b"hello"]

# ASGI:非同步,能處理 HTTP、WebSocket、長連線;receive/send 都是 awaitable
async def asgi_app(scope, receive, send):
    assert scope["type"] == "http"
    await send({"type": "http.response.start", "status": 200,
                "headers": [(b"content-type", b"text/plain")]})
    await send({"type": "http.response.body", "body": b"hello"})
WSGIASGI
模型同步 callableasync callable
Servergunicorn(sync / gthread / gevent worker)、uWSGIuvicorn、hypercorn、granian;或 gunicorn + uvicorn worker
框架Flask、Django(傳統)FastAPI、Starlette、Django(3.0+ 支援)、Quart
WebSocket / SSE / 長連線不適合原生支援
並發能力受限於 worker × thread 數單 worker 就能撐大量並發 I/O

gunicorn / uvicorn 的 worker 模型

flowchart LR C["Clients"] --> N["Nginx / LB"] N --> M["gunicorn master(不處理請求,只管理 worker)"] M --> W1["worker 1:uvicorn worker + event loop"] M --> W2["worker 2"] M --> W3["worker N(約等於 CPU 核心數)"] W1 --> D[("DB / Redis 連線池(每個 worker 一份)")]
  • pre-fork:master process 先 import app(若 --preload)再 fork 出 N 個 worker,共用 listening socket;master 負責監控、重啟掛掉或逾時的 worker、處理 HUP 平滑重啟。
  • worker 數常見經驗值:sync worker 用 2 × cores + 1(因為會阻塞在 I/O);async(uvicorn)worker 用約等於 cores,因為每個 worker 已能並發處理大量 I/O。最終要以壓測決定。
  • --max-requests + --max-requests-jitter:處理一定數量請求後重啟 worker,緩解慢性記憶體洩漏(治標)。
  • --timeout:worker 卡住超過時間被 master 殺掉重啟(sync worker 適用)。
  • 多 worker 意味著:in-process 快取(lru_cache、全域 dict)每個 worker 一份、連線池大小 = 每 worker 池大小 × worker 數 × Pod 數,要算進 DB 的 max_connections。
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 \
  --max-requests 10000 --max-requests-jitter 1000 --timeout 30 --graceful-timeout 20
# 或直接 uvicorn(在 K8s 上常讓每個 Pod 跑 1 個 worker,由 HPA 水平擴展)
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 --loop uvloop --http httptools

FastAPI / Flask / Django 差異

FastAPIFlaskDjango
定位API 優先、async、型別驅動微框架,自由組裝batteries-included 全端框架
介面ASGI(Starlette)WSGI(2.0 起支援 async view,但仍跑在 WSGI 上)WSGI + ASGI
驗證 / 序列化pydantic 自動驗證、自動產生 OpenAPI自選(marshmallow 等)Forms、DRF serializers
ORM自選(SQLAlchemy、SQLModel)自選內建 Django ORM + migrations + admin
適合高效能微服務、ML model serving小型服務、高度客製後台、CMS、需要 admin 與完整功能的產品
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
import asyncio

app = FastAPI()

class BidReq(BaseModel):
    request_id: str
    user_id: str
    floor_price: float = Field(gt=0, description="CPM 底價(USD)")
    slots: list[str] = Field(min_length=1)

    @field_validator("user_id")
    @classmethod
    def not_blank(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("user_id blank")
        return v

class BidResp(BaseModel):
    request_id: str
    price: float | None

async def get_model():                  # 依賴注入:測試時可用 app.dependency_overrides 替換
    return {"base": 1.2}

@app.post("/bid", response_model=BidResp)
async def bid(req: BidReq, model: dict = Depends(get_model)) -> BidResp:
    try:
        async with asyncio.timeout(0.08):           # 給自己留 buffer,超時就 no-bid
            price = await score(req, model)
    except TimeoutError:
        return BidResp(request_id=req.request_id, price=None)
    if price < req.floor_price:
        return BidResp(request_id=req.request_id, price=None)
    return BidResp(request_id=req.request_id, price=price)

async def score(req: BidReq, model: dict) -> float:
    await asyncio.sleep(0.005)                       # 例如查 Redis 特徵
    return model["base"] * len(req.slots)
# 驗證失敗時 FastAPI 自動回 422 與詳細錯誤欄位
FastAPI 常見陷阱

用 async def 宣告的 endpoint 跑在 event loop 上,裡面呼叫同步 DB driver 會卡住整個 worker;用一般 def 宣告的 endpoint,FastAPI 會自動丟到 thread pool 執行(預設上限 40 個 thread,由 AnyIO 控制)。所以「全部改成 async def」但底層還是同步 library,反而比較慢。

Appier 情境

以廣告平台常見做法為例:即時競價(RTB)要求在約 100ms 內回應,扣掉網路往返,服務本身可能只有數十毫秒。Python 適合做控制面(campaign 管理 API、報表 API、ML 模型 serving 的周邊),極端延遲敏感的 bidder 熱路徑常見以 Go / Java / C++ 實作,或以 Python 搭配大量 C 擴充與嚴格的 timeout / no-bid 降級。面試時展現「知道語言的取捨並能說明何時換語言」比堅持某一種語言更加分。

「為什麼 Python 會這樣」追問彙整 高頻

現象底層原因(一句話版)
預設參數 list 會累積def 執行時只求值一次並存在 __defaults__。
迴圈 lambda 都回傳最後一個值closure 捕捉變數(cell),呼叫時才查值(late binding)。
256 is 256 為 True,大數可能 FalseCPython 快取 -5~256 的小整數;同一 code object 常數會去重。
多執行緒跑 CPU 運算沒變快GIL:一次只有一個 thread 執行 bytecode。
有 GIL 仍需 LockGIL 只保護單一 bytecode,x += 1 是多條指令。
list 不能當 dict key可變 → hash 會變 → 破壞 hash table 不變式;所以 __hash__ = None。
dict 有順序但 set 沒有dict 3.6 改成 compact 結構(indices + 依插入順序的 entries);set 沒有這樣實作。
0.1 + 0.2 != 0.3IEEE 754 二進位浮點無法精確表示 0.1。
round(2.5) == 2預設 banker's rounding,降低大量四捨五入的統計偏差。
t[0] += [x] 拋錯但 list 已被改+= 先就地 __iadd__ 成功,再對 tuple 賦值失敗。
迭代中刪除 list 元素會跳過list iterator 只是遞增 index,刪除讓元素左移。
Python 迴圈比 C 慢很多直譯 dispatch + 動態型別查找 + 物件 boxing;用向量化或 C 擴充。
local 變數比 global 快LOAD_FAST 是陣列 index;global 是 dict 查找(還有 builtins fallback)。
記憶體用量遠大於資料本身每個物件有 header(refcount + type),int 至少 28 bytes;list 存的是指標。
process 記憶體在 del 後沒下降pymalloc 以 arena 管理小物件,arena 內還有存活物件就無法還給 OS;碎片化。
[[0]*3]*3 改一格全部變* 複製的是參考,三列是同一個 list。
-7 // 2 == -4// 是 floor division,讓 a == (a // b) * b + a % b 且餘數與除數同號。
可以 a, b = b, a右側先建立 tuple(或直接在 stack 上交換),再解包賦值。
為什麼要寫 self「Explicit is better than implicit」;method 就是函式,透過 descriptor 綁定 instance 成為第一個參數。
面試官想聽到什麼

這類追問的評分重點不是你背得出結論,而是能否用一致的心智模型(名字綁定物件、物件有 refcount 與型別、一切在執行期決定、CPython 是 bytecode 直譯器)推導出答案,並指出「這是語言規範還是 CPython 實作細節」。回答時養成習慣:現象 → 原因 → 如何避免 / 正確寫法 → 延伸(在 Go/Java 中如何)。

Go 速覽:給後端工程師的重點 進階

Appier 接受 Python / Go / Java。即使主力是 Python,懂 Go 的並行模型能讓你在系統設計題(高 QPS 服務、bidder)中更有說服力。Go 的設計目標:編譯快、部署簡單(單一靜態 binary)、原生並行、GC 停頓低。

goroutine 與 GMP 排程器(M:N)

G(goroutine)
使用者層級的輕量執行緒,初始 stack 只有約 2KB 且可動態成長,可以輕鬆開數十萬個。
M(machine)
OS thread。
P(processor)
邏輯處理器,持有一個 local run queue;數量 = GOMAXPROCS(預設為 CPU 核心數;Go 1.25 起在 Linux 上會考量 cgroup CPU limit)。M 必須綁定一個 P 才能執行 G。
flowchart TB GQ["Global run queue"] -.-> P1 subgraph P1["P1(local run queue)"] G1["G"] --- G2["G"] --- G3["G"] end subgraph P2["P2(local run queue)"] G4["G"] --- G5["G"] end P1 --> M1["M1 → OS thread → CPU core"] P2 --> M2["M2 → OS thread → CPU core"] P2 -. "local queue 空了就 work stealing" .-> P1 M3["M3:卡在 blocking syscall 的 thread"] -. "P 被交給其他 M" .-> P2
  • M:N:M 個 goroutine 對應到 N 個 OS thread,由 Go runtime 排程,切換成本遠低於 OS thread context switch。
  • Work stealing:P 的 local queue 空了會從 global queue 或其他 P 偷一半工作。
  • Syscall handoff:G 進入阻塞 syscall 時,M 連同 G 一起阻塞,P 被交給另一個 M 繼續跑其他 G。
  • Network poller:網路 I/O 在底層用 epoll,G 等待 socket 時被 park,不佔用 thread——所以寫起來是同步阻塞風格,底層卻是 I/O multiplexing。
  • 搶佔:Go 1.14 起有基於 signal 的非同步搶佔,緊密迴圈也不會餓死其他 goroutine。

channel、select、WaitGroup、Mutex

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs { // channel 被 close 且清空後迴圈結束
		time.Sleep(10 * time.Millisecond)
		results <- fmt.Sprintf("worker %d did job %d", id, j)
	}
}

func main() {
	jobs := make(chan int, 100)       // buffered channel
	results := make(chan string, 100)
	var wg sync.WaitGroup

	for w := 1; w <= 4; w++ { // worker pool
		wg.Add(1)
		go worker(w, jobs, results, &wg)
	}
	for j := 1; j <= 10; j++ {
		jobs <- j
	}
	close(jobs) // 只有 sender 該 close;對已 close 的 channel 送值會 panic

	go func() { wg.Wait(); close(results) }()
	for r := range results {
		fmt.Println(r)
	}

	// Mutex 保護共享狀態
	var mu sync.Mutex
	counter := map[string]int{}
	var wg2 sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg2.Add(1)
		go func() {
			defer wg2.Done()
			mu.Lock()
			counter["clicks"]++
			mu.Unlock()
		}()
	}
	wg2.Wait()
	fmt.Println(counter["clicks"]) // 100
}
// select:同時等多個 channel;搭配 time.After 做逾時
select {
case res := <-resultCh:
	fmt.Println("got", res)
case <-time.After(50 * time.Millisecond):
	fmt.Println("timeout → no-bid")
}

Go 的格言:「Don't communicate by sharing memory; share memory by communicating.」但實務上兩者並用:傳遞所有權/工作流用 channel,保護一小塊狀態用 sync.Mutex / sync.RWMutex / sync/atomic。sync.Once 做一次性初始化,sync.Pool 重用暫存物件以降低 GC 壓力。

context:取消與逾時的傳遞

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

func queryFeature(ctx context.Context, uid string) (float64, error) {
	select {
	case <-time.After(30 * time.Millisecond): // 模擬查 Redis
		return 0.42, nil
	case <-ctx.Done(): // 上游取消或逾時
		return 0, ctx.Err()
	}
}

func handleBid(parent context.Context) {
	ctx, cancel := context.WithTimeout(parent, 20*time.Millisecond)
	defer cancel() // 一定要呼叫,釋放 timer 資源
	v, err := queryFeature(ctx, "u1")
	if errors.Is(err, context.DeadlineExceeded) {
		fmt.Println("feature timeout, use default")
		return
	}
	fmt.Println("feature", v)
}

func main() { handleBid(context.Background()) }

慣例:ctx 當函式第一個參數往下傳;HTTP server 的 r.Context() 在客戶端斷線時會被取消,DB driver、HTTP client 都接受 ctx,整條呼叫鏈能一起停止,避免浪費資源(Python asyncio 的 cancellation 是類似概念)。

錯誤處理、interface、defer

package main

import (
	"errors"
	"fmt"
	"os"
)

var ErrNotFound = errors.New("not found") // sentinel error

type Store interface { // interface 是隱式實作的(structural,類似 Python Protocol)
	Get(key string) (string, error)
}

type MapStore struct{ m map[string]string }

func (s *MapStore) Get(key string) (string, error) {
	v, ok := s.m[key]
	if !ok {
		return "", fmt.Errorf("get %q: %w", key, ErrNotFound) // %w 包裝,保留錯誤鏈
	}
	return v, nil
}

func readConfig(path string) (err error) {
	f, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("open config: %w", err)
	}
	defer f.Close() // defer 以 LIFO 順序在函式 return 時執行,參數在 defer 當下求值
	return nil
}

func main() {
	var s Store = &MapStore{m: map[string]string{"a": "1"}}
	if _, err := s.Get("b"); errors.Is(err, ErrNotFound) {
		fmt.Println("handled:", err) // handled: get "b": not found
	}
	_ = readConfig("/etc/hosts")
}
  • 錯誤是值,不是例外:明確回傳、明確檢查,errors.Is / errors.As 檢查包裝鏈。panic/recover 只用於真正不可恢復的程式錯誤。
  • nil interface 陷阱:把值為 nil 的 *MyErr 指標回傳成 error 介面,介面本身不是 nil(type 欄位有值),err != nil 為 true。

slice 底層與 append 陷阱、map 非 thread-safe

package main

import "fmt"

func main() {
	// slice = {指向底層陣列的指標, len, cap}
	a := make([]int, 3, 10) // len 3, cap 10
	b := a[:2]              // 共用同一個底層陣列
	b = append(b, 99)       // cap 足夠 → 直接寫入底層陣列,覆蓋了 a[2]!
	fmt.Println(a, b)       // [0 0 99] [0 0 99]

	c := append(a, 1) // cap 足夠 → c 與 a 共用底層陣列
	c[0] = 7
	fmt.Println(a[0]) // 7

	d := make([]int, 3, 3)
	e := append(d, 1) // cap 不足 → 配置新陣列並複製,之後 e 與 d 互不影響
	e[0] = 7
	fmt.Println(d[0]) // 0

	// 安全做法:需要獨立副本時用 full slice expression 限制 cap,或明確 copy
	safe := a[:2:2] // len 2, cap 2,之後 append 一定會重新配置
	safe = append(safe, 5)
	_ = safe
}
  • map 不是 thread-safe:多個 goroutine 同時讀寫,runtime 會偵測並直接 fatal error: concurrent map writes(不是 panic,無法 recover)。用 sync.Mutex/RWMutex 包起來,或讀多寫少、key 穩定時用 sync.Map。
  • map 迭代順序刻意隨機化,不能依賴。
  • 用 go test -race / go run -race 開啟 race detector 抓 data race。
  • goroutine 洩漏:goroutine 卡在沒人讀的 channel 上永遠不會結束;用 context 取消與 pprof 的 goroutine profile 排查。

Python vs Go:效能與並行比較

Python(CPython)Go
執行方式bytecode 直譯編譯成原生機器碼(靜態連結 binary)
型別動態型別(可選 type hints)靜態型別,編譯期檢查
CPU-bound 效能純 Python 常慢數十倍;靠 NumPy/C 擴充補接近 Java,通常數十倍快於純 Python
並行GIL 限制多核;asyncio 處理 I/O 並發;多 process 吃滿核心goroutine + 多核心真平行,同步風格程式碼
記憶體物件 overhead 大struct 緊湊、值語意
GCrefcount + cycle GC並行 tri-color mark-sweep,停頓通常亞毫秒;用 GOGC、GOMEMLIMIT 調整
部署需要 runtime + 依賴單一 binary,容器 image 可極小
生態強項資料科學、ML、腳本、快速開發雲原生基礎設施(Docker、K8s)、高並發網路服務
Appier 情境

一個合理的論述:「ML 模型訓練與資料管線用 Python 生態最有效率;高 QPS、低延遲且需要多核心平行的線上服務(例如 bidder、事件收集 gateway)用 Go 能以更少機器、更穩定的 tail latency 撐住流量;兩者以 gRPC / Kafka 介接。」這顯示你理解語言選擇是工程取捨,而不是信仰。

面試自測題

Python 是編譯式還是直譯式語言?CPython 怎麼執行一個 .py 檔?

CPython 會先把原始碼經 tokenizer、PEG parser 轉成 AST,再編譯成 code object(bytecode),被 import 的模組會快取成 __pycache__ 下的 .pyc。接著由 stack-based 的虛擬機器(ceval 的 eval loop)逐條 dispatch 執行。3.11 起有 specializing adaptive interpreter,依執行期觀察到的型別把通用指令換成特化版本;3.13 起有實驗性 JIT。所以答案是「先編譯成 bytecode 再直譯」。追問「為什麼慢」:每條指令的 dispatch、動態型別的屬性查找、所有值都是 heap 上的物件;解法是向量化、C 擴充、或換 PyPy。

Python 參數傳遞是 by value 還是 by reference?

都不是,是 pass-by-object-reference(call by sharing):參數名字綁定到呼叫端傳入的同一個物件。在函式內就地修改可變物件(lst.append)呼叫端看得到;重新賦值(lst = [])只改變 local 名字的綁定,呼叫端看不到。不可變物件無法就地修改,所以行為像 by value。可以舉 mutate vs rebind 的例子。

a is b 與 a == b 差在哪?為什麼 256 is 256 是 True?

is 比較 identity(是否同一物件,CPython 即比較位址),不可 overload;== 呼叫 __eq__ 比較值。CPython 預先建立並重複使用 -5 到 256 的小整數,且同一個 code object 中相同的常數會去重、看起來像識別字的字串會被 intern,所以某些情況 is 剛好為 True。這是實作細節而非語言保證;只有和 singleton(None、True、False、sentinel)比較時用 is。

CPython 如何管理記憶體?循環參考怎麼回收?

主要靠 reference counting:計數歸零立即釋放,具確定性。循環參考由 cycle detector(gc 模組)處理:只追蹤容器物件,把候選集合內部互相參考的計數扣掉,剩下仍有外部參考的視為可達並標記,其餘就是垃圾。採分代策略,新物件在 young generation,存活後晉升,老物件掃描頻率低(≤3.13 三代、預設 threshold (700,10,10);3.14 改為 incremental 的 young/old 兩代)。小物件由 pymalloc 以 arena/pool 管理。追問:延遲敏感服務可用 gc.freeze() 把啟動時載入的常駐物件排除在掃描外、用 weakref 避免製造環。

什麼是 GIL?為什麼存在?有 GIL 為什麼還需要 Lock?

GIL 是 CPython 的直譯器全域鎖,同一時間只有一個 thread 執行 bytecode。存在的原因:reference count 的增減不是 atomic,GIL 用最小代價保護它與直譯器內部結構,讓單執行緒快、C 擴充好寫。影響:CPU-bound 多執行緒無法利用多核;I/O 時會釋放 GIL,所以 IO-bound threading 有效。仍需 Lock 是因為 GIL 只保證單一 bytecode 不被打斷,counter += 1 是 load/add/store 多條指令,中間可能切換造成遺失更新。現況:PEP 703 free-threaded build 3.13 實驗、3.14 正式支援但非預設。

CPU-bound 的 Python 服務要怎麼利用多核心?

(1) multiprocessing / ProcessPoolExecutor,或部署層面多個 worker process(gunicorn -w N)、多個 Pod;(2) 把熱點下放到會釋放 GIL 的原生程式碼:NumPy 向量化、Cython、Rust(PyO3)、C 擴充;(3) free-threaded Python(需確認依賴支援);(4) 架構上把重運算移到專用服務(Go/C++)或批次系統。要考慮 process 間資料傳遞的 pickle 成本、記憶體倍增(可用 fork + copy-on-write 共享唯讀資料,配合 gc.freeze)。

dict 的底層實作是什麼?為什麼 3.7 以後會保持插入順序?

open addressing 的 hash table,用 perturbation 探測序列解決碰撞,負載約 2/3 時擴容,平均 O(1)。3.6 起是 compact dict:一個稀疏的 indices 陣列(存小整數)加上一個依插入順序排列的密集 entries 陣列(hash, key, value)。因為 entries 本來就按插入順序,迭代自然有序,且更省記憶體、迭代更快。3.6 是實作細節,3.7 正式寫入語言規範。追問:刪除留下 dummy、resize 時壓縮;set 沒有這種結構所以無序。

為什麼 list 不能當 dict 的 key?自訂 class 當 key 要注意什麼?

dict 依 hash 定位 slot。list 可變,若 hash 依內容計算,修改後 hash 改變,就找不到原本的 slot;若依 identity 計算,內容相同的另一個 list 又找不到,語意錯誤。所以 list 的 __hash__ 為 None。自訂 class:必須遵守「相等則 hash 相等」,__hash__ 用參與 __eq__ 的同一組欄位,且作為 key 期間不可變——最好用 @dataclass(frozen=True) 或 NamedTuple。只定義 __eq__ 不定義 __hash__ 會讓 class 變成 unhashable。

說明 mutable default argument 與 late binding closure 兩個陷阱及解法。

預設參數在 def 執行時只求值一次、存在 __defaults__,所以預設 list 會被每次呼叫共用累積;解法是預設 None,在函式內建立新物件。late binding:迴圈中建立的 lambda/closure 捕捉的是變數本身(cell),呼叫時才讀值,所以全部看到迴圈結束時的值;解法是用預設參數 lambda i=i: i 或 functools.partial 在建立時綁定。Go 1.22 前的 goroutine 迴圈變數也有同樣問題。

Generator 和 list comprehension 差在哪?什麼時候用 generator?

list comprehension 立即建立完整 list,記憶體 O(n);generator 是惰性的,保存暫停的 frame,每次 next 才產生下一個值,記憶體 O(1)。適合:處理大檔案/串流(逐行讀 GB 級 log)、建構多階段 pipeline、無限序列、只需迭代一次的情況。缺點:只能迭代一次、不能 index、len。yield from 委派給子 generator 並轉發 send/throw/close,取得其 return 值,也是 async/await 的前身。

寫一個帶參數的 retry decorator,並說明為什麼要用 functools.wraps。

三層:最外層接收參數(times、backoff、exceptions)並回傳 decorator;decorator 接收 func 回傳 wrapper;wrapper 中迴圈呼叫 func,捕捉指定的 transient 例外,用 exponential backoff + jitter sleep,最後一次失敗時 raise 保留 traceback。functools.wraps 把原函式的 __name__、__doc__、__qualname__、__wrapped__、annotations 複製到 wrapper,否則 log、除錯、inspect.signature、FastAPI 的參數解析都會看到 wrapper。追問:async 函式要另寫 async wrapper;非冪等操作重試要配 idempotency key。

super() 是呼叫父類別嗎?什麼是 MRO?

不完全是。super() 回傳一個代理,呼叫的是「type(self) 的 MRO 中,目前 class 之後的下一個 class」的方法。MRO 用 C3 linearization 計算,保證子類別在父類別前、維持宣告順序、單調性。在菱形或 mixin 多重繼承中,super() 可能呼叫到兄弟類別,這讓每個 class 都呼叫 super() 時共同祖先只被呼叫一次(cooperative multiple inheritance)。可用 Cls.__mro__ 查看。

什麼是 descriptor?property 和 cached_property 有什麼差別?

實作 __get__/__set__/__delete__ 的物件,放在 class 上時攔截 instance 的屬性存取。data descriptor(有 __set__)優先於 instance __dict__;non-data descriptor(只有 __get__)則 instance dict 優先。property 是 data descriptor,每次存取都呼叫 getter;cached_property 是 non-data descriptor,第一次計算後寫入 instance __dict__,之後直接命中 dict。函式本身也是 non-data descriptor,所以 obj.method 會產生 bound method。

asyncio 的運作原理?在 async 函式裡呼叫 requests.get() 會怎樣?

單執行緒 event loop + 協作式排程:coroutine 執行到 await 一個未完成的 Future 時讓出控制權,loop 用 epoll 等待 fd 就緒、處理 timer,再恢復對應 coroutine。requests.get() 是同步阻塞呼叫,會卡住整個 loop,期間所有其他請求都無法前進,延遲全部飆高。解法:改用 httpx/aiohttp 等 async client;必須用同步 library 時用 asyncio.to_thread 或 run_in_executor;CPU-bound 丟 ProcessPoolExecutor。debug 模式可偵測超過 100ms 的 slow callback。

asyncio.gather 和 TaskGroup 差在哪?怎麼限制並發數與設定逾時?

gather 並發執行多個 awaitable 並依序回傳結果;預設第一個例外會傳給呼叫者但其他 task 繼續跑(不會自動取消),return_exceptions=True 則把例外當結果回傳。TaskGroup(3.11+)是 structured concurrency:任一 task 失敗會取消其他 task,並把錯誤彙整成 ExceptionGroup 拋出,生命週期清楚,不會留下孤兒 task。限制並發用 asyncio.Semaphore;逾時用 asyncio.timeout()(3.11+)或 wait_for,底層是取消 task,捕捉 CancelledError 做清理後要重新 raise。

WSGI 和 ASGI 差在哪?gunicorn 的 worker 數怎麼設?

WSGI 是同步介面,一個請求佔用一個 worker thread/process 直到完成;ASGI 是 async 介面,支援 WebSocket、長連線,一個 worker 的 event loop 可並發處理大量 I/O。gunicorn 是 pre-fork 模型:master 管理多個 worker process,監控與重啟。sync worker 常用 2×cores+1,uvicorn worker 約等於 cores(K8s 上也常每 Pod 1 個 worker 靠 HPA 擴展)。要考慮每個 worker 各自的連線池與 in-process 快取,總連線數 = 每 worker 池大小 × worker 數 × Pod 數,不能超過 DB 上限。最終以壓測為準。

Python 服務的記憶體一直上升,可能原因有哪些?

(1) 真洩漏:全域 dict/list 無上限成長(沒有淘汰的快取)、lru_cache 掛在 method 上持有 self、事件 listener 註冊後沒移除、未完成的 asyncio task 持有大物件;(2) 循環參考加上 GC 不夠頻繁或被 disable;(3) C 擴充洩漏;(4) 不是洩漏而是碎片化:pymalloc arena 無法歸還 OS,RSS 不下降。排查:tracemalloc 比較兩個 snapshot 找成長最多的配置點、gc.get_objects() 依型別計數、objgraph 看參考鏈、memray 看 native 配置;暫時緩解用 gunicorn --max-requests 定期重啟 worker。詳見第 8 章。

Go 的 goroutine 為什麼比 OS thread 輕量?GMP 是什麼?

goroutine 初始 stack 約 2KB 且可成長,建立與切換由 Go runtime 在 user space 完成,不需 kernel context switch。GMP:G 是 goroutine、M 是 OS thread、P 是邏輯處理器(數量 = GOMAXPROCS),M 必須持有 P 才能跑 G,每個 P 有 local run queue,空了就 work stealing。G 進入阻塞 syscall 時 P 會轉交其他 M;網路 I/O 由 netpoller(epoll)處理,等待中的 G 被 park 不佔 thread。Go 1.14 起有非同步搶佔。這讓 Go 能以同步風格的程式碼寫出高並發服務,且真正利用多核。

Go 的 slice append 有什麼陷阱?map 可以多個 goroutine 同時寫嗎?

slice 是 {ptr, len, cap} 的 header,多個 slice 可共用底層陣列。append 時若 cap 足夠會直接寫入底層陣列,可能覆蓋另一個 slice 看得到的元素;cap 不足才配置新陣列,此後兩者分離——行為取決於 cap,非常容易出 bug。解法:用 full slice expression a[:n:n] 限制 cap,或明確 copy。map 不是 thread-safe,並發寫會觸發 runtime fatal error(無法 recover);用 Mutex/RWMutex 保護或用 sync.Map,並用 -race 檢測。

如果要寫一個高 QPS 的 bidder,你會選 Python 還是 Go?為什麼?

以取捨回答:Go 在多核平行、低且穩定的 GC 停頓、低記憶體 overhead、單一 binary 部署上有優勢,適合 100ms 內必須回應、QPS 極高的熱路徑;Python 的優勢是開發速度與 ML 生態,適合模型訓練、特徵工程、控制面 API。若必須用 Python:多 process + asyncio、熱點用 C 擴充/向量化、嚴格 timeout 與 no-bid 降級、避免 GC 停頓(gc.freeze)、序列化用 orjson/protobuf。也可提出混合架構:Go bidder 透過 gRPC 呼叫 Python/Triton 模型服務,或把模型匯出成 ONNX 在 Go/C++ 端推論。