Scope LEGB + closure: vì sao hàm nhớ sai giá trị?
Scope LEGB + closure: vì sao [lambda: i for i in range(3)] cả ba đều trả 2
Bài 5 — series "Python cho dân backend".
Câu đố làm dev trung cấp cũng vấp:
fns = [lambda: i for i in range(3)]
print([f() for f in fns]) # tưởng [0, 1, 2]... thực ra [2, 2, 2] (!)
Tạo ba lambda trong vòng lặp, mỗi cái "đáng lẽ" giữ một giá trị i khác nhau. Nhưng cả ba đều trả 2. Vì sao? Đây là late binding trong closure — bẫy cực hay gặp khi đăng ký callback/handler trong vòng lặp.
Dùng AI coding agent? Late binding trong vòng lặp là một trong những bug AI hay tạo ra nhứt mà không cảnh báo — nó sinh code trông hoàn toàn hợp lệ, chạy không lỗi, chỉ ra kết quả sai.
UnboundLocalErrorthì còn thấy ngay; late binding thì âm thầm. Không hiểu closure thì đọc code AI xong cũng không thấy gì lạ — tới khi handler nào cũng chạy với giá trị cuối mới ngồi gãi đầu.
Trước hết, closure là gì? (ví dụ không dùng lambda)
Bỏ lambda qua một bên cho dễ hình dung. Closure là một hàm bên trong "nhớ" được biến của hàm bao ngoài, kể cả sau khi hàm ngoài đã chạy xong và trả về. Viết bằng def lồng def cho rõ:
def tao_nhan(he_so):
def nhan(x):
return x * he_so # nhan "nhớ" he_so của hàm bao ngoài
return nhan
nhan_doi = tao_nhan(2)
nhan_ba = tao_nhan(3)
print(nhan_doi(10)) # 20 → nhớ he_so = 2
print(nhan_ba(10)) # 30 → nhớ he_so = 3
tao_nhan chạy xong, đã return rồi — vậy mà nhan_doi vẫn biết he_so = 2, nhan_ba vẫn biết he_so = 3. Hàm bên trong "gói theo" biến của hàm ngoài để xài sau → đó chính là closure. Mấu chốt: mỗi lần gọi tao_nhan tạo ra một biến he_so MỚI riêng, nên hai closure nhớ hai giá trị khác nhau — chạy đúng như bây mong.
Nhưng closure bắt BIẾN, không bắt GIÁ TRỊ
Đây mới là chỗ đẻ ra cái đố đầu bài. Đa số nghĩ closure "chụp" giá trị tại thời điểm tạo. Sai:
Closure giữ tham chiếu tới biến, không phải bản sao giá trị. Nó đọc giá trị của biến đó lúc bị gọi, chứ không phải lúc tạo.
Ví dụ tao_nhan ở trên "có vẻ đúng" chỉ vì mỗi lần gọi tạo một he_so riêng. Còn trong vòng lặp thì ngược lại — chỉ có một biến i dùng chung cho cả ba hàm. Viết lại cái đố bằng def (khỏi lambda) cho thấy đây không phải lỗi của lambda:
fns = []
for i in range(3):
def f():
return i # cả ba f cùng nhớ MỘT biến i
fns.append(f)
print([f() for f in fns]) # [2, 2, 2] — y chang bản lambda
Ba hàm f cùng trỏ về một biến i (vòng lặp dùng đi dùng lại đúng một biến đó). Tới lúc bây gọi chúng — vòng lặp đã xong từ đời nào, i đang là 2 → cả ba đọc ra 2. Đó gọi là late binding: giá trị được đọc muộn, lúc gọi, không phải lúc tạo.
LEGB: Python tìm tên ở đâu
Khi gặp một tên, Python tra theo thứ tự LEGB:
- Local — trong hàm hiện tại.
- Enclosing — hàm bao ngoài (closure).
- Global — cấp module.
- Built-in —
len,print... Lambda ở trên tìmikhông thấy ở Local → lên Enclosing/Global, thấy cáiicủa vòng lặp → dùng chung nó.
Bẫy kèm theo: UnboundLocalError
Cùng họ scope, mà cắn người ta nhiều không kém. Gốc của nó là một quy tắc phải nằm lòng:
Python quyết một tên là local hay không NGAY LÚC BIÊN DỊCH hàm (trước khi chạy), bằng cách quét toàn bộ thân hàm: nếu một tên được GÁN ở BẤT KỲ ĐÂU trong hàm → tên đó là local cho CẢ hàm, mọi dòng — kể cả dòng trước chỗ gán.
x = 1
def f():
print(x) # UnboundLocalError!
x = 2
Đọc kỹ vì sao lỗi: vì có dòng x = 2 đâu đó trong f, Python đã quyết x là biến local của f — suốt cả hàm. Tới lúc chạy print(x), biến local x đó chưa được gán giá trị nào → "dùng trước khi gán" → UnboundLocalError. Nó KHÔNG lùi ra đọc x = 1 ở global, vì đã chốt x là local rồi.
Đối chiếu cho thấy chính phép GÁN mới là thủ phạm:
x = 1
def chi_doc():
print(x) # in ra 1 — OK, vì KHÔNG có dòng nào gán x → x là global (LEGB)
Không có gán → x được tra ra ngoài theo LEGB, đọc global bình thường. Có gán → x bị "local hóa" cả hàm → đọc trước khi gán là chết.
Ca đời thực hay dính nhứt — dùng += với biến ngoài:
dem = 0
def tang():
dem += 1 # UnboundLocalError!
dem += 1 vừa đọc vừa ghi dem. Vì có ghi → dem thành local cả hàm → lúc đọc (dem + 1) biến local chưa có giá trị → lỗi. Trông như "tao chỉ tăng cái biến đếm" mà sập, khó hiểu nếu không biết quy tắc trên.
Cách sửa:
global dem— nếu muốn ghi vào biến cấp module.nonlocal dem— nếu muốn ghi vào biến của hàm bao ngoài (trong closure).- Hoặc đổi tên biến local cho khỏi đụng tên ngoài; hoặc đọc giá trị ngoài vào một tên khác rồi mới xài.
dem = 0
def tang():
global dem
dem += 1 # OK
Cách sửa late binding: bind giá trị ngay lúc tạo
Mẹo kinh điển — dùng default argument để "chốt" giá trị i tại thời điểm tạo (vì default được tính ngay — nhớ bài 1):
fns = [lambda i=i: i for i in range(3)] # mỗi lambda chốt i riêng
print([f() for f in fns]) # [0, 1, 2] — đúng rồi
lambda i=i nghĩa: tham số i có default là giá trị hiện tại của i lúc tạo → mỗi cái giữ một bản riêng. Mẹo y vậy với def lồng (khỏi lambda):
fns = []
for i in range(3):
def f(i=i): # default i chốt giá trị tại thời điểm tạo
return i
fns.append(f)
print([f() for f in fns]) # [0, 1, 2]
Hệ quả thực chiến (bẫy)
Bẫy này cắn nhứt khi bây tạo callback/handler trong vòng lặp — đăng ký route, event handler, button onclick, task... Tất cả "dính" biến vòng lặp cuối cùng. Thấy mấy handler cứ chạy với giá trị cuối thay vì giá trị riêng → đích thị late binding.
Closure hay dùng để làm gì trong thực tế
Khi đã hiểu closure là "hàm mang theo ngữ cảnh", bây thấy nó xuất hiện khắp nơi:
1. Tạo hàm "cấu hình sẵn" (partial application). Thay vì viết đi viết lại cùng một tham số:
def nhan_voi(he_so):
def nhan(x):
return x * he_so
return nhan
gap_doi = nhan_voi(2)
gap_ba = nhan_voi(3)
print(gap_doi(5)) # 10
print(gap_ba(5)) # 15
Closure "đóng gói" he_so vào hàm → bây có hàm chuyên dụng mà không phải truyền he_so lại mỗi lần. (functools.partial làm điều tương tự cho hàm bất kỳ — nhưng biết closure thì hiểu partial cũng chỉ là vậy.)
2. Factory hàm — tạo nhiều hàm cùng kiểu, khác tham số. Ví dụ validator:
def tao_validator(min_val, max_val):
def validate(x):
return min_val <= x <= max_val
return validate
tuoi_hop_le = tao_validator(18, 65)
diem_hop_le = tao_validator(0, 10)
print(tuoi_hop_le(20)) # True
print(diem_hop_le(11)) # False
Mỗi lần gọi tao_validator → một closure riêng nhớ min_val/max_val của nó. Dùng nhiều mà không cần class.
3. Giữ trạng thái không cần class. Closure là cách đơn giản để có "state nhỏ" mà không phải dựng cả một class:
def tao_dem():
so_lan = 0 # biến "riêng" của closure
def dem():
nonlocal so_lan
so_lan += 1
return so_lan
return dem
dem_a = tao_dem()
dem_b = tao_dem() # instance riêng, state riêng
print(dem_a(), dem_a()) # 1 2
print(dem_b()) # 1 (không dính dem_a)
so_lan không lộ ra ngoài, không ai sửa được trực tiếp — giống attribute private của class, nhưng gọn hơn nhiều cho trường hợp đơn giản.
4. Nền tảng của decorator (bài 6). Cái wrapper bên trong decorator chính là một closure — nó "nhớ" func (hàm gốc) để gọi lại. Hiểu closure là hiểu decorator đến gốc.
5. Callback mang theo ngữ cảnh. Trong xử lý bất đồng bộ, bây hay cần truyền callback đã "biết" một số thông tin sẵn:
def xu_ly_don(ma_don):
def khi_thanh_cong(ket_qua):
print(f"Đơn {ma_don} thanh toán xong: {ket_qua}")
return khi_thanh_cong
cb = xu_ly_don("DH001")
cb("500k") # Đơn DH001 thanh toán xong: 500k
Closure mang theo ma_don mà không cần truyền lại — hữu ích khi đăng ký callback vào một system không biết context của bây (event bus, async queue...).
Tóm lại khi nào nghĩ tới closure: khi bây cần một hàm "nhớ" một ít thông tin từ lúc tạo ra mà không muốn truyền đi truyền lại làm tham số — và khi cái "trạng thái" đó đơn giản, không cần dựng cả class.
Checklist: bây nắm chưa?
- [ ] Closure là gì? (Hàm bên trong nhớ được biến của hàm bao ngoài, kể cả sau khi hàm ngoài chạy xong.)
- [ ] Closure bắt biến hay bắt giá trị? (Bắt biến — đọc giá trị lúc GỌI, không phải lúc tạo.)
- [ ] Vì sao
tao_nhanchạy đúng mà vòng lặp thì sai? (Mỗi lần gọitao_nhantạohe_soriêng; vòng lặp chỉ có MỘT biếnidùng chung.) - [ ] LEGB là thứ tự tra tên nào? (Local → Enclosing → Global → Built-in.)
- [ ] Python quyết một tên là local lúc nào, dựa vào gì? (Lúc biên dịch hàm; nếu tên được gán ở bất kỳ đâu trong hàm → local cả hàm.)
- [ ] Vì sao
dem += 1với biến global gâyUnboundLocalError? (Có ghi →demthành local cả hàm → lúc đọc biến local chưa được gán.) - [ ]
globalvsnonlocal? (Ghi biến cấp module vs biến hàm bao ngoài.) - [ ] Sửa late binding bằng cách nào? (Bind giá trị qua default arg:
lambda i=i:hoặcdef f(i=i).) - [ ] Kể vài ứng dụng thực tế của closure? (Hàm cấu hình sẵn/partial, factory validator, giữ state không cần class, nền tảng decorator, callback mang ngữ cảnh.)
Bài tới: "Decorator thực chất là gì — @ đó là phép thuật gì."
All rights reserved