← Clean Code: viết code dễ đọc

Bài 10 · Nâng cao · 24 phút

Code smell & refactoring

Biên soạn bởi Nguyễn Anh Tuấn

Nhận diện mã mùi (code smell): trùng lặp, hàm dài, tham số thừa, magic number, dead code; và refactoring an toàn TỪNG BƯỚC với test đỡ lưng.

Code smell là những đặc điểm trong code báo hiệu có thể có vấn đề thiết kế - không phải lỗi làm chương trình chạy sai, mà là dấu hiệu cấu trúc đang khó bảo trì, khó mở rộng, dễ sinh bug về sau.

Giống như mùi trong bếp: có thể chưa cháy, nhưng nên kiểm tra. Một số smell phổ biến nhất cần biết:

  • Duplication (trùng lặp): kẻ thù số 1 - cùng logic ở nhiều chỗ; sửa một chỗ dễ quên chỗ kia.
  • Long Function (hàm quá dài): hàm làm quá nhiều việc, người đọc phải cuộn màn hình để hiểu.
  • Long Parameter List (danh sách tham số dài): 4+ tham số rời, dễ nhầm thứ tự khi gọi.
  • Magic Number (số bí ẩn không tên): `86400`, `0.15`, `3` - không ai biết ý nghĩa nếu không đọc comment.
  • Dead Code (code không bao giờ chạy): code bị comment-out, hàm không ai gọi, nhánh sau return.

Smell không tự hết

Code smell không làm chương trình vỡ hôm nay - nhưng tích tụ và làm mỗi thay đổi về sau chậm hơn, dễ lỗi hơn. Bài Vì sao code sạch quan trọng đã mô hình hoá điều này: mess tích tụ tạo lực cản, không phải biến mất.

Nhận diện smell cần luyện tập - đọc và chọn. Mỗi đoạn code bên dưới có một smell chính, mèo con thử đoán trước khi xem giải thích:

🔍 Đoán xem: đây là code smell gì?

1/8
function discountForStudent(price: number): number {
  return price - price * 0.15;
}
function discountForEmployee(price: number): number {
  return price - price * 0.15;
}
  • Duplication thường ẩn sau "cùng logic khác biến" - đọc kỹ logic, không chỉ tên hàm.
  • Long Function nhận ra khi hàm cần comment "// Bước 1" để người đọc theo được.
  • Magic Number nguy hiểm nhất khi số có ý nghĩa đặc biệt (30 ngày, giảm 15%) mà không có tên.

Refactor có định nghĩa chính xác: thay đổi cấu trúc bên trong của code mà không thay đổi hành vi quan sát được từ bên ngoài. Không thêm tính năng, không sửa bug - chỉ làm code dễ đọc, dễ thay đổi hơn.

Điều kiện tiên quyết: phải có test đỡ lưng. Test xanh trước khi bắt đầu, test xanh sau mỗi bước nhỏ - đó là bằng chứng hành vi không đổi. Bài Unit test sạch đã dạy cách viết test đáng tin; bài này dùng chúng làm lưới an toàn.

Trước refactor - hàm processOrder làm 5 việc khác nhau

function processOrder(order: Order): void {
  // Kiem tra ton kho
  for (const item of order.items) {
    if (item.qty > getStock(item.id)) throw new Error('Het hang: ' + item.id);
  }
  // Giam ton kho
  for (const item of order.items) {
    decreaseStock(item.id, item.qty);
  }
  // Tinh tong gia
  let total = 0;
  for (const item of order.items) {
    total += item.price * item.qty;
  }
  // Ap dung coupon
  if (order.coupon === 'VIP10') total *= 0.9;
  if (order.coupon === 'SALE20') total *= 0.8;
  // Thanh toan va thong bao
  charge(order.customerId, total);
  sendConfirmation(order.customerId, total);
}

Sau refactor - mỗi hàm con một việc, processOrder chỉ điều phối

// Buoc 1: extract function - chay test sau moi buoc
function validateStock(items: OrderItem[]): void {
  for (const item of items) {
    if (item.qty > getStock(item.id)) throw new Error('Het hang: ' + item.id);
  }
}

function deductStock(items: OrderItem[]): void {
  for (const item of items) decreaseStock(item.id, item.qty);
}

function calcTotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}

// Buoc 2: replace magic string - dat ten ro y dinh
const COUPON_DISCOUNT: Record<string, number> = {
  VIP10: 0.9,
  SALE20: 0.8
};

function applyDiscount(total: number, coupon?: string): number {
  return coupon ? total * (COUPON_DISCOUNT[coupon] ?? 1) : total;
}

// Ket qua: ham dieu phoi doc nhu ke hoach hanh dong
function processOrder(order: Order): void {
  validateStock(order.items);
  deductStock(order.items);
  const total = applyDiscount(calcTotal(order.items), order.coupon);
  charge(order.customerId, total);
  sendConfirmation(order.customerId, total);
}
  • Refactor = cấu trúc đổi, hành vi giữ nguyên - không thêm tính năng trong cùng commit.
  • Mỗi bước nhỏ: extract một hàm, chạy test xanh, commit - rồi mới bước tiếp.
  • Test đỡ lưng là bắt buộc: không có test thì đó là "viết lại mạo hiểm", không phải refactor.

Refactor có tên gọi chuẩn cho từng phép - biết tên giúp mèo con giao tiếp rõ với đồng đội và tra cứu nhanh hơn. Bốn phép hay dùng nhất:

1. Extract Function - tách logic thành hàm con có tên

// Truoc: doan tinh gia bi chon trong ham dai
function processOrder(order: Order): void {
  // ... nhieu logic khac ...
  let total = 0;
  for (const item of order.items) {
    total += item.price * item.qty;
  }
  // ...
}

// Sau: logic tinh gia duoc dat ten ro rang, co the test doc lap
function calcTotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
// processOrder goi calcTotal - khong doi hanh vi

2. Rename - đặt lại tên nói rõ ý định

// Truoc: ten viet tat, phai doc than ham moi hieu
function d(u: User[]): User[] {
  return u.filter((x) => x.a && x.t <= 30);
}

// Sau: ten noi ro y dinh, doc chu ky la hieu
function activeUsersSeenRecently(users: User[]): User[] {
  return users.filter((u) => u.isActive && u.daysSinceLogin <= 30);
}
// Chi doi ten - hanh vi y het; chay test ngay sau khi doi

3. Replace Magic Number with Constant - đặt tên cho số bí ẩn

// Truoc: 2592000000 la gi? Nguoi doc phai tu tinh
function isSessionExpired(createdAt: Date): boolean {
  return Date.now() - createdAt.getTime() > 2592000000;
}

// Sau: hang so noi ro don vi va y nghia
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;

function isSessionExpired(createdAt: Date): boolean {
  return Date.now() - createdAt.getTime() > THIRTY_DAYS_MS;
}

4. Introduce Parameter Object - gom tham số liên quan thành object

// Truoc: 8 tham so roi, de nham thu tu
function createUser(
  firstName: string, lastName: string,
  email: string, phone: string,
  street: string, city: string, postal: string,
  role: string
): User { /* ... */ }

// Sau: gom nhom theo y nghia, kieu bao dam thu tu dung
interface UserProfile {
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
}

interface Address {
  street: string;
  city: string;
  postalCode: string;
}

function createUser(profile: UserProfile, address: Address, role: string): User {
  /* ... */
}
  • Extract Function: hàm dài → tách hàm con có tên; hàm con có thể test độc lập.
  • Rename: tên mơ hồ → tên nói rõ ý định; IDE hỗ trợ rename an toàn toàn bộ dự án.
  • Replace Magic Number with Constant: số bí ẩn → hằng số có tên giải thích đơn vị và ý nghĩa.
  • Introduce Parameter Object: 4+ tham số liên quan → một interface gom lại; gọi hàm dễ hơn, ít nhầm thứ tự.

Dưới đây là danh mục rút gọn để kiểm code trước khi commit - không phải luật cứng mà là câu hỏi cần hỏi:

  • Hàm này làm mấy việc? Nếu hơn một việc rõ ràng, xem xét tách.
  • Có đoạn nào copy-paste ở chỗ khác không? Nếu sửa đây có cần sửa kia không?
  • Có magic number nào không? Đặt tên hằng số cho chúng.
  • Có code bị comment-out không? Xoá đi - git lưu lịch sử rồi.
  • Tên hàm/biến có phải đọc thân hàm mới hiểu không? Nếu có thì đổi tên.
  • Danh sách tham số có hơn 3 phần tử không? Xem xét introduce parameter object.

Áp dụng quy tắc Boy Scout

Không cần đại tu tất cả cùng lúc. Mỗi lần chạm một file, chọn một smell rõ nhất và refactor nó - chạy test, commit riêng. Hàng chục lần dọn nhỏ làm codebase sạch dần mà không có ngày "dừng ship để refactor". Bài tiếp theo - Thiết kế đơn giản - đưa vòng lặp này vào một khuôn tổng quát hơn: bốn quy tắc thiết kế đơn giản của Kent Beck.

Câu hỏi thường gặp

Bug làm chương trình chạy sai - cần sửa ngay. Code smell không làm sai chức năng mà báo hiệu "thiết kế này sẽ khó bảo trì". Có cần sửa không? Tuỳ: nếu đoạn đó không ai đụng vào nữa thì để nguyên cũng được; nếu sắp sửa hoặc mở rộng thì sửa trước. Đừng refactor vì refactor - refactor vì đang cần thay đổi ở đó.

Đây là vòng gà-trứng kinh điển. Giải pháp: viết "characterization test" - test ghi lại hành vi HIỆN TẠI (không phán xét đúng sai) để làm lưới an toàn. Sau khi có lưới mới refactor. Kỹ thuật này quan trọng đến mức có hẳn một bài trong dự án cuối khoá (bài 12).

Nguyên tắc ba lần (Rule of Three): lần đầu viết thẳng; lần hai copy là "hơi khó chịu nhưng chưa vội"; lần ba - hoặc khi thấy hai bản lệch nhau vì ai đó sửa một chỗ mà quên chỗ kia - thì nhất định tách ra. Không phải mọi trùng lặp đều xấu: đôi khi hai đoạn giống nhau nhưng sẽ phát triển theo hướng khác nhau - hãy hỏi "nếu tôi đổi một bản, tôi có muốn đổi bản kia không?" trước khi gộp.

Không có con số tuyệt đối. Nguyên tắc thực dụng hơn: nếu đọc hàm mà phải cuộn màn hình để nhớ phần trên là gì, hay nếu hàm cần comment như "// Bước 1:", "// Bước 2:" để người đọc theo được - đó là dấu hiệu nên tách ra. Hàm ngắn không tự nhiên là tốt; hàm tách đúng ra theo TRÁCH NHIỆM mới tốt.

Khi một nhóm tham số luôn xuất hiện cùng nhau ở nhiều hàm (vd firstName, lastName, email, phone), gom chúng thành một object riêng (vd UserProfile). Khi đó hàm nhận ít tham số hơn, đặt tên dễ hơn, và nếu cần thêm trường thì sửa một chỗ. Dùng khi thấy cùng một nhóm tham số lặp ở ít nhất 2-3 chỗ.

Đó là việc của test. Nếu có đủ test phủ hành vi quan trọng và test xanh trước và sau refactor - bạn có bằng chứng. Nếu chưa có test, hãy viết characterization test trước (mục FAQ ở trên). Đây là lý do bài Unit test sạch đặt ngay trước bài này trong lộ trình.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Code smell là gì và khác bug ở điểm nào?

  1. 1

    Smell hunt - đi săn mùi

    Mở một file trong dự án của mèo con. Tìm ít nhất 2 code smell khác nhau trong file đó. Ghi lại: smell tên gì, nằm ở đâu (tên hàm/dòng), vì sao đó là smell đó.

    Hoàn thành khi: Danh sách ít nhất 2 smell với tên chính xác (duplication/long function/...) và giải thích cụ thể - không phải "hàm này dài" mà "hàm X dài vì làm 4 việc: A, B, C, D".

  2. 2

    Extract function an toàn

    Chọn một hàm dài trong dự án (hoặc ví dụ processOrder ở Bước 3). Tách ra ít nhất một hàm con bằng phép extract function. Chạy test (nếu có) sau khi tách.

    Hoàn thành khi: Hàm gốc ngắn hơn và gọi hàm con mới có tên rõ ý định; hành vi không đổi (test xanh hoặc tự kiểm thủ công bằng cùng input/output).

  3. 3

    Magic number thành hằng số

    Tìm ít nhất 2 magic number trong code (của mình hoặc mã nguồn mở). Thay bằng hằng số const có tên giải thích ý nghĩa.

    Hoàn thành khi: Hai hằng số được đặt tên nói rõ đơn vị/ý nghĩa; chỗ dùng thay số bằng hằng số; code compile (hoặc chạy) như cũ.

  4. 4

    Introduce Parameter Object

    Tìm một hàm có 4 tham số trở lên (hoặc dùng ví dụ createUser ở Bước 3). Áp dụng introduce parameter object: định nghĩa một interface gom các tham số liên quan, sửa chữ ký hàm.

    Hoàn thành khi: Hàm còn tối đa 3 tham số; interface mới có tên rõ; chỗ gọi truyền object thay vì danh sách dài; TypeScript không báo lỗi.

  5. 5

    Dọn dead code

    Tìm và xoá dead code trong một file: code bị comment-out, hàm/biến không bao giờ được gọi, nhánh code sau return. Commit riêng bước này.

    Hoàn thành khi: Một commit chỉ xoá dead code (không đổi logic); chương trình chạy y như trước; commit message nói rõ xoá gì và tại sao.

  6. 6

    Refactor từng bước có test

    Chọn một đoạn code có smell. Viết test ghi lại hành vi hiện tại (characterization test) trước khi sửa. Rồi áp dụng một phép refactor, chạy test xanh, commit. Lặp lại ít nhất 2 lần.

    Hoàn thành khi: Ít nhất 2 commit nhỏ, mỗi commit một phép refactor; test xanh sau mỗi commit; commit message ghi rõ phép refactor đã dùng.