Seri này đi từ nhỏ tới lớn: một hàm, một lớp, một nhóm lớp, rồi cả một hệ thống nhiều máy. Bài đầu bắt đầu ở mức nhỏ nhất, một hàm, với một câu hỏi thực tế: code "sạch" là gì, và làm sao dọn một đoạn code bẩn mà không làm hỏng nó?
"Sạch" ở đây không phải chuyện thẩm mỹ. Một đoạn code được viết một lần nhưng được đọc hàng chục lần: lúc sửa lỗi, lúc thêm tính năng, lúc review, lúc người mới vào đọc để hiểu hệ thống. Code sạch là code mà chi phí để hiểu và thay đổi nó thấp.
Và ngày nay, "người đọc" không còn chỉ là người. Một phần ngày càng lớn code được đọc, sửa và viết thêm bởi agent lập trình: Claude Code, Copilot, Cursor, Codex... (bài Harness của agent trong seri LLM mổ xẻ chúng chạy thế nào). Agent là một kiểu người đọc rất đặc biệt: nó không nhớ gì giữa các phiên, mỗi lần làm việc là đọc lại từ đầu; nó tìm code bằng cách tìm chữ; nó đọc qua một cửa sổ ngữ cảnh có giới hạn; và quan trọng nhất, nó bắt chước code xung quanh. Mọi quy tắc trong bài đều có hai lý do: cho người, và cho agent.
Lộ trình: một hàm tính tiền khó đọc, lưới an toàn trước khi sửa, đặt tên, số ma thuật, gỡ if lồng nhau, tách hàm theo một mức trừu tượng, đo độ phức tạp trước và sau, viết comment cho đúng chỗ, code nhìn từ phía agent, rồi những lúc "clean code" đi quá đà (kể cả khi người viết là agent).
Mọi con số trong bài đều đo trên máy thật, với Python 3.12.
Một hàm khó đọc
Đây là hàm tính tổng tiền một đơn hàng, kiểu code rất hay gặp sau vài năm "sửa nhanh cho kịp":
def calc(o, u):
t = 0
if o is not None:
if len(o["items"]) > 0:
for i in o["items"]:
if i["qty"] > 0:
t += i["price"] * i["qty"]
if u is not None and u["vip"]:
if t > 1000000:
t = t * 0.9
else:
t = t * 0.95
else:
if t > 1000000:
t = t * 0.97
if o["country"] == "VN":
t = t * 1.1
elif o["country"] == "SG":
t = t * 1.09
else:
t = t * 1.0
return round(t)
else:
return 0
else:
return 0
Thử trả lời nhanh: khách VIP mua đơn 2 triệu giao tới Singapore thì trả bao nhiêu? Bạn sẽ phải dò từng nhánh if, nhớ t đang mang nghĩa gì ở dòng nào (tổng trước giảm giá, sau giảm giá, hay sau thuế?), và đoán 0.97 với 1.09 là gì. Hàm này chạy đúng. Vấn đề là mỗi lần đọc nó tốn vài phút, và mỗi lần sửa nó là một lần đánh cược, dù người sửa là bạn hay một agent.
Bước 0: lưới an toàn trước khi sửa
Quy tắc quan trọng nhất của việc dọn code: không đổi hành vi. Refactor nghĩa là thay đổi cấu trúc, giữ nguyên kết quả. Muốn chắc điều đó, cần một bài test chạy được trước khi sửa dòng nào.
Hàm cũ không có test, và cũng không có tài liệu nói nó "nên" làm gì. Cách thực tế nhất là characterization test: coi hàm cũ là đáp án chuẩn, sinh thật nhiều đầu vào ngẫu nhiên, và đòi hàm mới cho cùng kết quả:
import random
from messy import calc
from clean import order_total
random.seed(1)
for _ in range(20_000):
items = [{"price": random.choice([0, 1000, 50000, 999999, 2000000]),
"qty": random.randint(-1, 3)} for _ in range(random.randint(0, 4))]
order = random.choice([None, {"items": items, "country": random.choice(["VN", "SG", "US"])}])
user = random.choice([None, {"vip": True}, {"vip": False}])
assert calc(order, user) == order_total(order, user), (order, user)
Đầu vào được chọn có chủ đích: giá quanh ngưỡng 1 triệu, số lượng âm và bằng 0, đơn rỗng, None, ba nước. Sau mỗi bước sửa bên dưới, chạy lại bài test này. Phiên bản cuối cùng cho cùng kết quả trên cả 20.000 đơn ngẫu nhiên.
Bài test này không chứng minh hai hàm giống nhau tuyệt đối, nhưng nó biến việc refactor từ "hi vọng là không hỏng" thành "đã kiểm tra 20.000 trường hợp, kể cả các ngưỡng". Với agent, điều này còn quan trọng hơn: agent sửa code nhanh hơn người nhiều lần, và câu "xong rồi, đã refactor" của nó chỉ đáng tin khi có một lệnh test để nó tự chạy và tự thấy đỏ. Viết bài test này trước, rồi giao việc refactor cho agent, là một cách làm việc rất hiệu quả: bạn định nghĩa "đúng" là gì, agent lo phần tay chân, và bài test là trọng tài.
Bước 1: tên nói lên ý nghĩa
o, u, t, i, calc: người viết biết chúng là gì, người đọc thì không. Một cái tên tốt trả lời câu hỏi "cái này là gì, dùng để làm gì" mà không cần đọc phần còn lại:
| Trước | Sau | Vì sao |
|---|---|---|
calc |
order_total |
Tính cái gì? |
o, u |
order, user |
Không phải đoán |
t |
amount |
Một biến, một nghĩa ở mỗi thời điểm |
i |
item |
i thường là chỉ số, ở đây lại là một món hàng |
u is not None and u["vip"] |
is_vip |
Đặt tên cho một điều kiện |
Tên dài hơn không có nghĩa là tốt hơn. item trong một vòng lặp 2 dòng là đủ, không cần current_order_line_item. Phạm vi càng rộng, tên càng cần rõ: một biến sống trong 2 dòng có thể ngắn, một hàm được gọi từ 10 nơi thì không.
Tên còn có một vai trò mà ngày trước ít ai để ý: nó là thứ để tìm. Agent (và cả người, trong một repo lạ) tìm code bằng grep. Mục "code nhìn từ phía agent" bên dưới đo xem chuyện đó khác nhau thế nào giữa hai phiên bản.
Bước 2: không có số ma thuật
1000000, 0.97, 1.09 là số ma thuật: giá trị có ý nghĩa nghiệp vụ nhưng không có tên. Khi thuế ở Singapore đổi, bạn phải tìm mọi chỗ có 1.09 và đoán chỗ nào là thuế, chỗ nào tình cờ cũng là 1.09. Đặt tên cho chúng, và gom những thứ cùng loại vào một bảng:
BIG_ORDER = 1_000_000 # VND; trên mức này, giảm giá nhiều hơn
VAT = {"VN": 0.10, "SG": 0.09}
Bảng VAT còn làm lộ ra một điều thú vị: nhánh else: t = t * 1.0 của hàm cũ là code chết, nhân với 1 không làm gì cả. Trong bảng, nó trở thành giá trị mặc định VAT.get(country, 0.0), và thêm một nước mới là thêm một dòng dữ liệu, không phải thêm một nhánh elif.
Bước 3: thoát sớm thay vì lồng nhau
Hàm cũ lồng 4 tầng: if trong if trong for trong if. Phần việc chính nằm ở tầng sâu nhất, còn hai nhánh else: return 0 nằm tít cuối hàm, cách điều kiện của chúng 20 dòng. Đọc tới return 0 cuối cùng, bạn phải cuộn ngược lên xem nó thuộc về if nào.
Cách sửa là guard clause: xử lý các trường hợp đặc biệt ngay đầu hàm và thoát luôn:
if not order or not order["items"]:
return 0
Sau dòng này, phần còn lại của hàm được phép giả định đơn hàng tồn tại và có hàng, và nó không cần thụt vào hai tầng nữa.
Bước 4: mỗi hàm một việc, một mức trừu tượng
Hàm cũ trộn ba việc ở ba mức chi tiết khác nhau: cộng tiền từng món (chi tiết), quyết định mức giảm giá (quy tắc nghiệp vụ), áp thuế (quy tắc nghiệp vụ khác). Tách mỗi việc thành một hàm có tên, và hàm chính chỉ còn kể lại câu chuyện:
BIG_ORDER = 1_000_000 # VND; trên mức này, giảm giá nhiều hơn
VAT = {"VN": 0.10, "SG": 0.09}
def subtotal(items):
return sum(item["price"] * item["qty"] for item in items if item["qty"] > 0)
def discount_rate(amount, is_vip):
big = amount > BIG_ORDER
if is_vip:
return 0.10 if big else 0.05
return 0.03 if big else 0.0
def order_total(order, user=None):
if not order or not order["items"]:
return 0
amount = subtotal(order["items"])
amount *= 1 - discount_rate(amount, is_vip=bool(user and user["vip"]))
amount *= 1 + VAT.get(order["country"], 0.0)
return round(amount)
Câu hỏi lúc đầu giờ trả lời được trong vài giây: VIP, 2 triệu, lớn hơn BIG_ORDER nên giảm 10%, Singapore thuế 9%: 2.000.000 × 0,9 × 1,09 = 1.962.000. Và mỗi quy tắc giờ có một chỗ ở riêng: đổi chính sách giảm giá chỉ chạm discount_rate, đổi thuế chỉ chạm VAT. discount_rate còn test riêng được, không cần dựng cả một đơn hàng.
Đo trước và sau
"Dễ đọc hơn" nghe chủ quan, nhưng có vài thứ đo được. Một đoạn script nhỏ dùng module ast của Python để đếm, cho mỗi hàm: số dòng, độ phức tạp vòng (cyclomatic complexity: 1 cộng số điểm rẽ nhánh như if, vòng lặp, and/or, tức là số đường đi độc lập qua hàm), và độ lồng sâu nhất:
| Hàm | Số dòng | Độ phức tạp | Lồng sâu nhất |
|---|---|---|---|
calc (cũ) |
26 | 11 | 4 |
subtotal |
2 | 3 | 0 |
discount_rate |
5 | 4 | 1 |
order_total |
7 | 4 | 1 |
Tổng số dòng gần như không đổi. Cái thay đổi là không hàm nào còn phức tạp quá 4. Độ phức tạp 11 nghĩa là có 11 đường đi độc lập qua một hàm, và muốn test hết phải có ít nhất chừng đó trường hợp, mỗi trường hợp phải dựng cả một đơn hàng. Ba hàm nhỏ, mỗi hàm 3–4 đường đi, test riêng từng hàm dễ hơn nhiều. Các công cụ như ruff (luật C901) hay radon đo đúng chỉ số này, và nhiều đội đặt ngưỡng khoảng 10 cho mỗi hàm. Đặt ngưỡng đó vào linter cũng là một cách "dặn" agent: code nó viết ra phải qua được lint như code của người.
Comment: giải thích vì sao, không phải cái gì
Comment kiểu t += price * qty # cộng giá nhân số lượng không giúp gì: code đã nói điều đó rồi, và khi code đổi, comment thường bị quên và bắt đầu nói dối. Phần lớn comment kiểu "cái gì" biến mất khi đặt tên tốt.
Comment có giá trị là comment trả lời câu hỏi mà code không tự trả lời được: vì sao lại làm thế này, điều gì không hiển nhiên, cái bẫy nào đã gặp. Một ví dụ thật trong code của chính trang web này:
# Not in every system's MIME table (the slim Docker image lacks it); browsers expect font/woff2 for fonts
mimetypes.add_type("font/woff2", ".woff2")
Đọc dòng code, bạn biết nó làm gì. Đọc comment, bạn biết vì sao nó tồn tại, và biết không được xóa nó chỉ vì "trên máy mình chạy vẫn ổn".
Với agent, loại comment này là hàng rào bảo vệ. Một agent được giao "dọn code thừa" sẽ thấy một dòng đăng ký kiểu MIME mà trên máy phát triển không cần tới, chạy test vẫn xanh (vì bộ test không chạy trong image Docker rút gọn), và rất có lý do để xóa nó. Comment "vì sao" là thứ duy nhất trong file nói cho nó biết điều mà test không bắt được. Kiến thức nằm trong đầu một người thì agent không đọc được; kiến thức nằm trong comment, trong tên, trong test thì đọc được.
Code nhìn từ phía agent
Ba thói quen của agent làm cho code sạch quan trọng hơn trước, và cả ba đều đo hoặc thấy được trên chính ví dụ trong bài.
1. Agent tìm code bằng cách tìm chữ. Giao cho agent "đổi mức giảm giá cho khách VIP", việc đầu tiên nó làm thường là tìm những chữ như discount, vat, total trong repo. Đếm số dòng khớp trong hai phiên bản:
| Từ khóa | Hàm cũ calc |
Phiên bản mới |
|---|---|---|
discount |
0 | 3 |
vat |
0 | 2 |
total |
0 | 3 |
vip |
1 | 3 |
Hàm cũ không chứa chữ "discount" nào: quy tắc giảm giá nằm trong đó nhưng không có tên. Agent (hay một người mới vào dự án) phải mở và đọc từng file nghi ngờ để đoán ra, tốn thời gian, tốn ngữ cảnh, và dễ sửa nhầm chỗ. Đặt tên tốt là làm cho code tìm được.
2. Agent đọc qua một cửa sổ ngữ cảnh có giới hạn. Ở ví dụ nhỏ này, tổng số token gần như bằng nhau: hàm cũ 199 token, cả phiên bản mới 193 token (đếm bằng bộ tách token o200k_base). Khác biệt nằm ở lượng phải đọc cho một thay đổi: muốn sửa giảm giá ở phiên bản mới, chỉ cần đọc discount_rate và hằng số BIG_ORDER, khoảng 72 token; ở hàm cũ, phải đọc và hiểu cả 199 token vì quy tắc nằm xen giữa mọi thứ khác. Trong một repo thật hàng trăm nghìn dòng, khác biệt đó nhân lên: hàm nhỏ, file tập trung, module chia theo miền giúp agent nạp đúng phần cần, thay vì nhét nửa repo vào ngữ cảnh rồi đọc lướt.
3. Agent bắt chước code xung quanh. Đây là điểm quan trọng nhất. Agent viết code mới dựa trên code đang có: nó nhìn quanh xem dự án này làm việc tương tự thế nào, rồi làm theo. Giao "thêm Thái Lan, thuế 7%" cho cùng một agent:
- Với hàm cũ, cách tự nhiên nhất là thêm một nhánh
elif o["country"] == "TH": t = t * 1.07, thêm một số ma thuật, nối dài chuỗiif. Code bẩn sinh thêm code bẩn, và mỗi lần thêm lại làm khuôn mẫu cho lần sau. - Với phiên bản mới, cách tự nhiên nhất là thêm
"TH": 0.07vào bảngVAT. Một dòng, không đụng logic.
Nói cách khác, code hiện tại của bạn chính là prompt cho mọi đoạn code agent sẽ viết tiếp. Một repo nhất quán, sạch, có mẫu rõ ràng thì agent viết thêm cũng nhất quán và sạch; một repo mỗi chỗ một kiểu thì agent sẽ chọn đại một kiểu, thường là kiểu gần nhất.
Thêm vào đó, các agent hiện nay đọc một file hướng dẫn ở gốc repo (CLAUDE.md, AGENTS.md, hay file rules tương đương) trước khi làm việc. Đó là chỗ ghi những gì không tự suy ra từ code được: lệnh chạy test và lint (ở repo này là uv run pytest và ./scripts/lint.sh), quy ước đặt thư mục, những thứ không được đụng vào. Giữ nó ngắn và đúng: một file hướng dẫn lỗi thời cũng nói dối như một comment lỗi thời, chỉ là được đọc ở mọi phiên.
Khi "clean code" đi quá đà
Mọi quy tắc trên đều có điểm mà làm thêm sẽ hại nhiều hơn lợi:
- Tách quá nhỏ. Mười hàm một dòng, mỗi hàm gọi hàm tiếp theo, buộc người đọc nhảy qua mười chỗ để hiểu một việc. Tách khi phần tách ra có một cái tên rõ nghĩa và một lý do để tồn tại riêng, không phải vì một quy tắc "hàm không quá 5 dòng".
- Trừu tượng hóa quá sớm. Hai đoạn code trông giống nhau chưa chắc đã là cùng một khái niệm. Gộp chúng lại rồi hai yêu cầu dần đi hai hướng, và hàm chung mọc thêm cờ
if mode == .... Một quy tắc thực dụng: đợi tới lần lặp thứ ba (rule of three) rồi mới gộp. Lặp code còn rẻ hơn một trừu tượng sai. - Hiệu năng. Mỗi lần gọi hàm trong Python tốn khoảng 14 ns (đo ở bài sau của seri). Với một hàm tính tiền gọi vài lần mỗi request, chuyện đó không đáng kể. Trong một vòng lặp nóng chạy hàng chục triệu lần, nó đáng kể, và ở đó, đo trước rồi mới quyết.
Những cái bẫy này áp dụng nguyên vẹn cho code do agent viết, và một số còn hay gặp hơn. Viết code với agent gần như không tốn công, nên dễ sinh ra quá nhiều code: một lớp bọc không ai cần, một try/except nuốt mọi lỗi "cho chắc", một tham số cấu hình cho thứ không bao giờ đổi, comment kể lại từng dòng code làm gì. Code viết nhanh nhưng vẫn phải được đọc, review và bảo trì với cùng chi phí như code người viết. Vì vậy hãy review code của agent bằng đúng tiêu chuẩn của bài này: tên có rõ không, có số ma thuật không, có nhánh nào không cần không, có lặp lại mẫu sẵn có trong repo hay tự bày ra một kiểu mới. Đoạn diff ngắn nhất làm đúng việc thường là đoạn tốt nhất.
Clean code là một công cụ để giảm chi phí thay đổi, không phải mục đích. Câu hỏi đúng không phải "code này có theo quy tắc không" mà là "người tiếp theo, hay agent tiếp theo, có hiểu và sửa được nó nhanh và đúng không". Người tiếp theo đó thường là chính bạn sau ba tháng, đang nhờ một agent đọc hộ.
Tóm tắt
| Thực hành | Cho người | Cho agent |
|---|---|---|
| Test trước khi refactor | Giữ nguyên hành vi; 20.000 đơn ngẫu nhiên cho cùng kết quả | Một lệnh để agent tự kiểm tra, thay vì tin lời "xong rồi" |
| Tên rõ nghĩa | Hiểu một dòng mà không cần đọc cả hàm | Tìm được bằng grep: "discount" 0 khớp so với 3 |
| Hằng số có tên, bảng dữ liệu | Một quy tắc, một chỗ để sửa | Thêm nước mới thành một dòng dữ liệu, không thành một elif |
| Guard clause, một hàm một việc | Độ phức tạp từ 11 xuống tối đa 4 | Chỉ cần đọc ~72 thay vì 199 token cho một thay đổi |
| Comment "vì sao" | Ghi lại điều code không tự nói được | Hàng rào ngăn agent xóa thứ mà test không bắt được |
| Mẫu nhất quán, file hướng dẫn | Dễ đoán cách làm | Code hiện tại là prompt cho code agent viết tiếp |
| Đừng quá đà | Đợi lần lặp thứ ba | Review code agent viết bằng cùng tiêu chuẩn; diff ngắn nhất làm đúng việc |
Bài này dừng ở một hàm. Khi code lớn lên thành nhiều lớp và module phụ thuộc vào nhau, câu hỏi đổi thành: chia trách nhiệm thế nào, và phụ thuộc nên chỉ theo hướng nào để thay một phần mà không kéo đổ cả hệ thống? Bài sau: năm nguyên lý SOLID, áp vào Python.