0

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. UnboundLocalError thì 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ìm i không thấy ở Local → lên Enclosing/Global, thấy cái i củ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 xbiế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_nhan chạy đúng mà vòng lặp thì sai? (Mỗi lần gọi tao_nhan tạo he_so riêng; vòng lặp chỉ có MỘT biến i dù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 += 1 với biến global gây UnboundLocalError? (Có ghi → dem thành local cả hàm → lúc đọc biến local chưa được gán.)
  • [ ] global vs nonlocal? (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ặc def 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí