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

Bài 3 · Vận dụng · 22 phút

Hàm nhỏ, làm một việc

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

Hàm nhỏ làm MỘT việc ở một mức trừu tượng; ít tham số (tránh 3+ & cờ boolean); tránh tác dụng phụ ẩn; command-query separation; thay if/switch lặp bằng đa hình.

Quy tắc đầu tiên của hàm: hàm nên nhỏ. Quy tắc thứ hai: hàm nên nhỏ hơn nữa. Câu đùa thực ra có lý: hàm dài thường là dấu hiệu nó đang làm nhiều hơn một việc.

Hàm dài làm nhiều việc vs các hàm nhỏ rõ vai trò

// Truoc: mot ham lam het - validate, tinh, luu, gui email
async function processOrder(order: Order): Promise<void> {
  if (!order.items || order.items.length === 0) {
    throw new Error('Don hang trong');
  }
  if (!order.customerId) {
    throw new Error('Thieu khach hang');
  }
  let total = 0;
  for (const item of order.items) {
    total += item.price * item.quantity;
  }
  if (order.coupon) {
    total = total * (1 - order.coupon.discount);
  }
  await db.orders.save({ ...order, total });
  await emailService.sendConfirmation(order.customerId, total);
}

// Sau: moi ham mot viec; doc processOrder la hieu toan bo luong
async function processOrder(order: Order): Promise<void> {
  validateOrder(order);
  const total = calculateTotal(order);
  await saveOrder(order, total);
  await notifyCustomer(order.customerId, total);
}

function validateOrder(order: Order): void { /* ... */ }
function calculateTotal(order: Order): number { /* ... */ }
async function saveOrder(order: Order, total: number): Promise<void> { /* ... */ }
async function notifyCustomer(customerId: string, total: number): Promise<void> { /* ... */ }

Phiên bản sau đọc như mục lục: bạn hiểu toàn bộ luồng từ bốn dòng, rồi chỉ đi vào hàm con khi cần biết chi tiết cách từng bước thực hiện.

  • Hàm làm một việc: nếu tên cần "và" thì đó là dấu hiệu cần tách.
  • Hàm nhỏ tên rõ đọc như mục lục - hiểu luồng mà không cần đọc chi tiết.
  • Mỗi hàm nên làm một việc ở một mức trừu tượng nhất định.

Hàm khó đọc không chỉ vì dài - còn vì lẫn lộn mức trừu tượng. Một hàm xử lý cả nghiệp vụ cấp cao lẫn chi tiết kỹ thuật cấp thấp giống như đọc một bản hướng dẫn mà nửa đầu nói "bật lò nướng lên", nửa sau nói "kiểm tra điện trở nhiệt ngẫu".

Lẫn lộn mức trừu tượng vs nhất quán một mức

// Xau: tron cai cao (logic don hang) voi cai thap (xu ly string)
function generateInvoice(order: Order): string {
  const lines: string[] = [];
  // cap cao: logic don hang
  if (order.isPremium) applyPremiumDiscount(order);
  // cap thap: xu ly string, padding, format
  lines.push('INVOICE'.padStart(20, ' '));
  lines.push('-'.repeat(40));
  for (const item of order.items) {
    const price = item.price.toFixed(2).padStart(10);
    lines.push(`${item.name.padEnd(28)}${price}`);
  }
  return lines.join('\n');
}

// Tot: ham cap cao goi ham cap thap; moi ham nhat quan mot muc
function generateInvoice(order: Order): string {
  if (order.isPremium) applyPremiumDiscount(order);
  return formatInvoice(order);
}

function formatInvoice(order: Order): string {
  return [
    formatHeader(),
    ...order.items.map(formatLineItem),
  ].join('\n');
}

function formatLineItem(item: OrderItem): string {
  return `${item.name.padEnd(28)}${item.price.toFixed(2).padStart(10)}`;
}
  • Đọc hàm từ trên xuống: mỗi dòng nên ở cùng mức trừu tượng với các dòng còn lại.
  • Tách chi tiết cấp thấp ra hàm con - hàm cấp cao trở thành câu chuyện dễ đọc.
  • Quy tắc "stepdown": hàm A gọi B, B cùng mức hoặc thấp hơn A - không nhảy cóc.

Hàm lý tưởng không có tham số. Hàm một tham số vẫn dễ hiểu. Hàm hai tham số cần chú ý thứ tự. Hàm ba tham số trở lên bắt đầu gây khó - người gọi phải nhớ thứ tự, nhớ ý nghĩa từng vị trí, và dễ đặt nhầm.

Hàm 3+ tham số vs gom thành object param

// Kho: 3 tham so - thu tu nao? don vi nao? moi khi goi lai phai xem
function createUser(name: string, age: number, role: string): User { /* ... */ }
createUser('Meo Con', 5, 'student'); // age va role co the bi doi cho?

// Tot: object param voi type ro rang - khong can nho thu tu
type CreateUserParams = {
  name: string;
  age: number;
  role: 'student' | 'teacher' | 'admin';
};
function createUser(params: CreateUserParams): User { /* ... */ }
createUser({ name: 'Meo Con', age: 5, role: 'student' }); // ro rang

Flag argument - tham số boolean điều hướng hành vi - là dấu hiệu hàm đang làm hai việc. render(data, true) gọi là gì? true nghĩa là gì? Giải pháp: tách thành hai hàm riêng với tên rõ:

Flag argument vs hai hàm rõ ý định

// Xau: true/false dieu huong hanh vi - ham lam 2 viec
function render(data: ReportData, printMode: boolean): string { /* ... */ }
render(data, true);   // che do in? hay gì? phai vao xem
render(data, false);

// Tot: hai ham rieng biet, ten noi ro hanh vi
function renderForScreen(data: ReportData): string { /* ... */ }
function renderForPrint(data: ReportData): string { /* ... */ }
  • Hàm 0-2 tham số lý tưởng; 3+ nên gom thành object param với type rõ ràng.
  • Flag argument (boolean) là dấu hiệu hàm đang làm 2 việc - tách ra 2 hàm.
  • Object param không cần nhớ thứ tự; tên field tự giải thích; TypeScript bắt lỗi kiểu.

Side effect ẩn là khi hàm làm thêm điều gì đó ngoài điều tên nó hứa hẹn - và người gọi không biết. Ví dụ: checkPassword() nghe như chỉ kiểm tra, nhưng bên trong nó xoá session của người dùng nếu sai. Người gọi tin tên hàm rồi bị bất ngờ.

Side effect ẩn: hàm làm nhiều hơn tên hứa

// Nguy hiem: checkPassword nghe nhu chi kiem tra, nhung xoa session
function checkPassword(userId: string, password: string): boolean {
  const user = userRepo.getById(userId);
  if (!user) return false;
  if (!bcrypt.compare(password, user.passwordHash)) {
    sessionManager.clearSession(userId); // side effect an!
    return false;
  }
  return true;
}

// Tot: ten phan anh day du hanh vi; hoac tach ra ham rieng
function checkPasswordAndClearSessionOnFailure(userId: string, password: string): boolean { /* ... */ }
// Hoac tot hon: tach ra
function verifyPassword(userId: string, password: string): boolean { /* ... */ }
function handleFailedLogin(userId: string): void { /* ... */ }

Command-query separation (CQS): hàm hoặc làm một việc (command) - đổi state, không trả dữ liệu có ý nghĩa; hoặc trả lời một câu hỏi (query) - không có side effect. Hai vai trò này không trộn lẫn:

Command-query separation

// Xau: set va kiem tra trong cung mot ham - CQS bi vi pham
function setAttribute(name: string, value: string): boolean {
  if (attribute.exists(name)) {
    attribute.set(name, value); // LAM (command)
    return true;                // KIEM TRA (query) - tron lan!
  }
  return false;
}
if (setAttribute('username', 'meocon')) { /* ... */ } // set hay kiem tra?

// Tot: tach command va query ro rang
function hasAttribute(name: string): boolean {
  return attribute.exists(name);       // query: chi tra loi, khong side effect
}
function setAttribute(name: string, value: string): void {
  attribute.set(name, value);          // command: chi lam, khong tra data
}

if (hasAttribute('username')) {
  setAttribute('username', 'meocon'); // ro rang: kiem tra truoc, set sau
}

Exception hơn mã lỗi

Trả về mã lỗi (như return -1 hay return null) ép người gọi phải kiểm tra mỗi lần, dễ bỏ sót. Ném exception thì luồng lỗi tách hẳn khỏi luồng thành công - người gọi xử lý ở catch, không lẫn với logic chính. TypeScript có thêm union type (User | null) hoặc Result pattern cho lỗi "có thể xảy ra bình thường".
  • Side effect ẩn: hàm làm thêm ngoài điều tên hứa - bẫy ngầm cho người gọi.
  • CQS: hàm hoặc LÀM (command, void) hoặc TRẢ LỜI (query, không side effect) - không trộn.
  • Exception tách luồng lỗi khỏi luồng thành công; trả null/mã lỗi ép người gọi nhớ kiểm tra.

If/switch theo "loại" (type, kind, role…) không phải xấu - nhưng khi cùng một if/switch xuất hiện ở nhiều hàm khác nhau cho cùng một bộ loại, đó là dấu hiệu: thêm một loại mới phải sửa ở nhiều chỗ, dễ quên và dễ vỡ.

If/switch lặp vs đa hình (polymorphism)

// Xau: if/switch theo type lap o nhieu ham - them loai la sua nhieu cho
function calculatePay(employee: Employee): number {
  if (employee.type === 'fulltime') return employee.salary / 12;
  if (employee.type === 'parttime') return employee.hoursWorked * employee.hourlyRate;
  if (employee.type === 'contractor') return employee.invoiceAmount;
  throw new Error('Unknown type');
}
function getDescription(employee: Employee): string {
  if (employee.type === 'fulltime') return 'Nhan vien chinh thuc';
  if (employee.type === 'parttime') return 'Nhan vien ban thoi gian';
  if (employee.type === 'contractor') return 'Nha thau';
  throw new Error('Unknown type');
}

// Tot: da hinh - them loai chi can them lop; diem goi khong doi
interface Employee {
  calculatePay(): number;
  getDescription(): string;
}
class FullTimeEmployee implements Employee {
  calculatePay() { return this.salary / 12; }
  getDescription() { return 'Nhan vien chinh thuc'; }
}
class PartTimeEmployee implements Employee {
  calculatePay() { return this.hoursWorked * this.hourlyRate; }
  getDescription() { return 'Nhan vien ban thoi gian'; }
}
class Contractor implements Employee {
  calculatePay() { return this.invoiceAmount; }
  getDescription() { return 'Nha thau'; }
}

Bài tiếp theo: Bình luận

Hàm nhỏ với tên rõ đã giảm rất nhiều nhu cầu comment. Nhưng comment không bao giờ biến mất hoàn toàn - vấn đề là biết khi nào cần và khi nào không. Bài 4 bàn đúng điều đó.
  • If/switch theo loại ở một chỗ thường ổn; cùng if/switch ở nhiều chỗ là dấu hiệu cần đa hình.
  • Đa hình: thêm loại mới = thêm lớp; điểm gọi không đổi - đúng tinh thần Open/Closed.
  • TypeScript interface là công cụ tự nhiên cho đa hình - không cần kế thừa phức tạp.

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

Không có con số vàng. Sách Clean Code gợi ý 5-10 dòng; thực tế quan trọng hơn là: hàm chỉ làm MỘT việc, đọc được không cần cuộn, và có thể đặt tên mô tả đầy đủ chỉ với một câu động từ. Nếu hàm cần tên như validateAndSaveAndNotify thì chắc chắn cần tách.

Có thể, nếu tách không đặt tên tốt. Hàm nhỏ với tên rõ thì ngược lại: đọc hàm cấp cao giống đọc mục lục - bạn hiểu toàn bộ luồng mà không cần đọc chi tiết từng hàm con. Chỉ vào hàm con khi cần biết cách nó thực hiện. Vấn đề thường là tên hàm con mơ hồ, không phải tách nhiều quá.

Exception cho lỗi "ngoài dự kiến" (mạng mất, file không tồn tại, dữ liệu lạ). Result/Either type (hoặc union type trong TS) cho lỗi "có thể xảy ra trong vận hành bình thường" (validation, không tìm thấy, quyền bị từ chối). TypeScript hỗ trợ cả hai; quan trọng là NHẤT QUÁN: không trộn lẫn mã lỗi số với exception trong cùng một hàm.

Rất áp dụng được và thường làm tự nhiên khi đặt tên đúng. Hàm đặt tên là động từ hành động (save, delete, process) thì đừng trả dữ liệu - hoặc nếu phải trả, trả chỉ để báo thành công/thất bại. Hàm đặt tên là câu hỏi (isValid, getUser, canRetry) thì không nên có side effect. Ranh giới đôi khi mờ (vd giao dịch DB cần đọc rồi ghi), nhưng đặt tên trung thực là bước đầu tiên.

Cả hai đều được; interface thường gọn hơn và không ràng buộc kế thừa. Dùng interface khi chỉ cần định nghĩa "hợp đồng" (contract) - lớp implement có thể kế thừa từ bất kỳ đâu. Dùng abstract class khi có logic chung cần chia sẻ giữa các implementation. Với đa hình để thay if/switch, interface thường là lựa chọn sạch hơn.

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

Hàm render(data, true) vi phạm nguyên tắc gì và giải pháp là gì?

  1. 1

    Tìm hàm làm nhiều việc

    Mở một file trong dự án của mèo con, tìm một hàm mà tên của nó cần dùng "và" (validateAndSave, fetchAndProcess…) hoặc thân hàm có nhiều khối logic rõ rệt. Viết ra các "việc" mà nó đang làm.

    Hoàn thành khi: Tên hàm và danh sách ≥2 việc tách được; nếu tách ra, tên hàm con mới sẽ là gì.

  2. 2

    Gom tham số thành object

    Tìm một hàm có 3+ tham số trong codebase (hoặc tự viết ví dụ). Refactor bằng cách gom các tham số liên quan thành một object param với type rõ ràng.

    Hoàn thành khi: Chữ ký hàm trước và sau; type mới; hàm vẫn biên dịch và hành vi không đổi.

  3. 3

    Tách flag argument

    Tìm (hoặc viết) một hàm nhận tham số boolean để chọn hành vi (vd render(data, true) vs render(data, false)). Tách thành hai hàm riêng với tên rõ.

    Hoàn thành khi: Hai hàm mới với tên diễn tả hành vi cụ thể; không còn tham số boolean điều hướng.

  4. 4

    Tìm side effect ẩn

    Đọc kỹ một hàm trong dự án. Hàm đó có làm thêm gì ngoài điều tên nó hứa hẹn không (đổi state bên ngoài, gọi API, ghi log, đổi tham số đầu vào)? Liệt kê các side effect đó.

    Hoàn thành khi: Tên hàm + danh sách side effect thực tế; nếu có side effect ẩn, đề xuất cách đặt tên trung thực hơn hoặc cách tách ra.

  5. 5

    Áp dụng command-query separation

    Viết (hoặc tìm) một hàm vừa thực hiện hành động vừa trả kết quả (vd saveAndReturn(item)). Tách thành hai: một command (không trả dữ liệu có ý nghĩa) và một query (không có side effect).

    Hoàn thành khi: Hai hàm riêng; gọi được tuần tự để đạt kết quả như trước; tên phản ánh đúng vai trò.

  6. 6

    Thay if/switch bằng đa hình

    Viết (hoặc tìm) một hàm chứa if/switch phân nhánh theo "loại" (type, kind, role…). Refactor bằng interface + các lớp implement; xoá if/switch khỏi điểm gọi.

    Hoàn thành khi: Interface với phương thức chung; ≥2 lớp implement; hàm ban đầu đơn giản lại (gọi interface, không còn if/switch về loại).