+3

Clean code #4: Viết các Hàm (Function) (P1)

1. Nguyên tắc khi viết các Function:

Trong bài viết này chúng ta sẽ giải quyết các vấn đề Clean cho Function bên dưới:

  • Tạo các function (hàm) có signal to noise ratio cao để dễ đọc.
  • Tạo ra một hàm có ý nghĩa.
  • Các kỹ thuật để duy trì sự đơn giản trong mã.
  • Code smell cần tránh và các kỹ thuật tái cấu trúc (refactoring) để loại bỏ hoàn toàn các vấn đề này.
  • Các kỹ thuật xử lý lỗi (error handling).

Để bắt đầu chúng ta cần phân biệt Method (phương thức) với Function (hàm):

  • Cả hai đều chỉ là những đoạn code được gọi theo tên.
  • Sự khác biệt cốt lõi là Method được liên kết với một đối tượng (Object). (Có thể nói nôm na là method nằm trong 1 class, còn function có thể nằm độc lập và không thuộc Class nào)
  • Hai thuật ngữ này có thể thay thế cho nhau.
  • Các nguyên tắc clean sắp trình bày áp dụng như nhau cho Method và Function.

2. Khi nào thì tạo một Function:

Các Function giúp chúng ta sắp xếp mã của mình thành các phần logic nhỏ có mục tiêu cụ thể. Điều này có nhiều lợi ích giống như các đoạn văn khi viết. Và giống như các đoạn văn, bạn sẽ dễ hiểu hơn nhiều về những gì người viết đang nói đến khi đoạn văn có mục đích rõ ràng và không lan man về các chủ đề có vẻ không liên quan.

Có 4 lý do phổ biến để tạo một hàm:

a. Để tránh trùng lặp (Duplication)

Clean Code tôn trọng Nguyên tắc DRY và không lặp lại.

b. Thụt lề là dấu hiệu của sự phức tạp

Thụt lề là dấu hiệu của sự phức tạp. Khi code thụt lề quá sâu, nó trở nên khó hiểu và khó bảo trì. Vì vậy, đây là một lý do tuyệt vời để tạo một Function. Code quá hài hoặc thụt lề quá mức sẽ rất khó đọc, vì vậy một số developer cho rằng đây là lý do duy nhất để tạo Function, chia nhỏ function lớn và dài ấy thành nhiều function nhỏ hơn để dễ đọc. Do đó có nhiều lý do khác khiến hàm hữu ích, ngay cả khi code chỉ được gọi một lần.

c. Hiểu được ý định của lập trình viên

Hiểu được ý định của lập trình viên là phần khó. Vì vậy, chúng ta có thể sử dụng tên Function được lựa chọn kỹ lưỡng để cung cấp tóm tắt cấp cao về logic của chúng ta giống như tiêu đề trong sách hỗ trợ hiểu và điều hướng. Comment cũng có thể phục vụ mục đích này, nhưng nếu bạn sắp viết comment, bạn có thể tạo một function/method có tên hay giúp truyền đạt ý định của bạn.

d. Để giúp duy trì một trách nhiệm duy nhất

Để giúp duy trì một trách nhiệm duy nhất, các function/method nên thực hiện một việc và chỉ một việc. Một function/method thực hiện một việc không nên quá dài. Hãy nhớ rằng các đoạn văn dài cũng trở nên nhàm chán. Lý do này dựa trên nguyên tắc Single-responsibility principle của SOLID.

3. Ngăn chặn Duplication:

Tầm quan trọng của việc tạo function/method là khi tôi cần gọi cùng một logic nhiều lần. Việc sao chép và dán mã khiến việc bảo trì trở nên khó khăn hơn, đặc biệt là khi sửa lỗi (fix bug), khi tôi phải đảm bảo rằng mình đã thay đổi nhiều chỗ.

DRY (Đừng lặp lại chính mình) là một trong những nguyên tắc quan trọng nhất trong phát triển phần mềm vì nó giúp đảm bảo chúng ta giảm thiểu số dòng mã phải bảo trì. Duplicated code có nghĩa là có hai nơi cần bảo trì, sửa chữa và hai nơi cần đọc trùng lặp.

4. Thụ lề quá mức:

Lý do thứ hai để tạo function/method là thụt lề quá mức (Excessive Indentation).

Khi logic code của chúng ta trở nên phức tạp hơn, chúng ta thường thấy hình dạng mũi tên. Mã mũi tên (Arrow Code) được xác định bằng hình dạng mũi tên của nó, đây là tham chiếu đến các cấp độ thụt lề sâu.

🔴 Dirty

// Arrow code
if
    if 
        if
            if
                do stuff
             end if
         end if    
     end if
 end if

Arrow Code là dấu hiệu cho thấy một phương thức có độ phức tạp cyclomatic cao. Độ phức tạp cyclomatic cao làm giảm khả năng đọc, cản trở việc thử nghiệm và làm tăng khả năng xảy ra lỗi trong một phương thức nhất định.

Hình dạng mũi tên này cũng gây tổn hại đến khả năng hiểu bằng cách giảm signal to noise. Người đọc phải duy trì tất cả các layer code của function trong đầu cùng một lúc. Và tác động tiêu cực của các mức độ lồng ghép sâu cũng đã được ghi nhận trong các nghiên cứu. Một nghiên cứu năm 1986 của Noam Chomsky và Gerald Weinberg phát hiện ra rằng khả năng hiểu giảm đi sau ba cấp độ khối 'If' lồng nhau. Mã lồng nhau quá mức này thường được gọi là Mã mũi tên (Arrow Code).

Có ba phương pháp đơn giản để loại bỏ Mã mũi tên độc hại này: Trích xuất phương thức (Extract a Method), Thất bại nhanh (Fail Fast) và Trả về sớm (Return Early). Chúng ta hãy tìm hiểu nó ở các phần tiếp theo.

5. Extract Method:

Khi tái cấu trúc một method/function, chúng ta chỉ cần di chuyển các phần logic liên quan đến các method được đặt tên hợp lý để truyền đạt ý định tốt hơn và giúp chúng ta loại bỏ thụt lề quá sâu. Khi tái cấu trúc Arrow Code, cách đơn giản nhất thường là bắt đầu với mã lồng nhau sâu nhất và thực hiện tách dần dần.

🔴 Before

if
    if 
        // thụt lề quá sâu
        while
            do
            some
            complicated
            thing
         end while
         // .....
     end if
 end if    

🟢 After

if
    if 
        doComplicatedThing()
     end if
 end if
 
 // split a method
doComplicatedThing() 
{
   while
       // do complicated some thing
    end while
}

Di chuyển vòng lặp while này đến một method. Như bạn có thể thấy, mức độ thụt lề được giảm và hình dạng mũi tên (arrow) trong code ít rõ nét hơn.

Việc tái cấu trúc một function/method có lợi ích không chỉ là loại bỏ Mã mũi tên (Arrow Code) và giảm độ phức tạp của method. Nó cũng tăng cường khả năng đọc vì người đọc method này giờ đây có thể bỏ qua một dòng lệnh nếu họ không quan tâm đến chi tiết về cách hoạt động của phần phức tạp mà chúng ta vừa di chuyển. Method này vẫn có thể được những người quan tâm tham khảo, nhưng nó không gây thêm nhiễu thông tin khi người ta chỉ muốn hiểu bức tranh toàn cảnh của tính năng.

Khi một class được tái cấu trúc thành nhiều method nhỏ thực hiện tốt một chức năng, người đọc có thể chọn đọc ở cấp độ trừu tượng hữu ích nhất cho mục đích của họ. Nếu người đọc đang tìm kiếm cái nhìn tổng quan ở cấp độ cao về thuật toán thì các chữ ký method cấp cao nhất sẽ cung cấp điều đó và nếu họ muốn biết chi tiết thì các method con sẽ có ở đó và tên của chúng sẽ cung cấp sự rõ ràng về mục đích.

Cuối cùng, việc tái cấu trúc một function/method mang lại những lợi ích tương tự như các chú thích mà chúng ta thấy trong bài viết này.

6. Return Early:

Trả về sớm (Return Early) là một cách tiếp cận hữu ích khác để loại bỏ thụt lề sâu trong mã của chúng ta. Ý tưởng lớn nhất của việc Return Early là nếu bạn không còn việc gì để làm thì hãy quay lại. Hãy xem một ví dụ. Như bạn có thể thấy nó được thụt sâu từ câu lệnh if đầu tiên:

// Return Early
private bool ValidUserAddress(string address) {
  bool isValid = false;
  
  const int MinAddressLength = 6;
  if(address.Length >= MinAddressLength)
  {
      const int MaxAddressLength = 25;
      if(address.Length <= MaxAddressLength)
      {
          bool isAlphaNumeric = address.All(Char.IsLetterOrDigit);
          if(isAlphaNumeric)
          {
              if(!ContainsCurseWords(address))
              {
                  isValid = IsUniqueAddress(address);  
              }
          }
      }
  }
  return isValid;
}

Như bạn thấy, nó được thụt sâu. Method này đang xác thực địa chỉ người dùng (ValidAddress). Có nhiều lý do khiến địa chỉ người dùng có thể không hợp lệ. Lưu ý: biến cục bộ isValid được dùng để lưu trữ trạng thái cho biết địa chỉ người dùng có hợp lệ hay không và mặc định là false ở đầu phương thức. Chỉ khi cả bốn câu lệnh if được đánh giá là đúng thì address người dùng mới được coi là hợp lệ.

Method này có bốn cấp thụt lề, làm giảm khả năng đọc. Chúng ta hãy cùng xem xét phiên bản Return Early.

// Return Early
private bool ValidAddress(string address) {
  const int MinAddressLength = 6;
  if(address.Length < MinAddressLength)
      return false;
     
  const int MaxAddressLength = 25;
  if(address.Length < MaxAddressLength)
      return false;
        
  bool isAlphaNumeric = address.All(Char.IsLetterOrDigit);
  if(isAlphaNumeric)
      return false;
     
  if(ContainsCurseWords(address))
      return false;
         
  return IsUniqueAddress(address);  
}

Lưu ý: Trong phiên bản này không có thụt lề nào cả. Cũng không cần biến isValid tạm thời được tạo trong phiên bản khác. Thay vào đó, với mỗi lần đánh giá, chúng tôi kiểm tra một thuộc tính điều đó sẽ làm cho địa chỉ người dùng trở nên không hợp lệ và chúng tôi sẽ trả về ngay khi phát hiện địa chỉ người dùng không hợp lệ. Điều này tạo ra một method có nhiều giá trị trả về (return), một số người có thể thấy không mong muốn.

Nhiều năm trước, logic phổ biến là chỉ cho phép một câu lệnh return cho mỗi hàm. Tuy nhiên, Returning Early thực sự có thể giúp thuần hóa mã thụt lề sâu và do đó cải thiện khả năng đọc và bảo trì.

Sử dụng lệnh return khi nó giúp tăng khả năng đọc…Trong một số thói quen nhất định, khi bạn đã biết câu trả lời…không return ngay lập tức có nghĩa là bạn phải viết thêm mã. (Steve McConnell, sách Code Complete)

Returning Early cũng hữu ích vì nó mô phỏng quá trình ra quyết định trong cuộc sống thực. Khi chúng ta biết không có lý do gì để tiếp tục, chúng ta sẽ dừng lại. Và bạn có thể thấy nó có liên quan đến phần 11. Prioritize short conditions chúng ta đã đề cập ở phần trước.

7. Fail Fast:

Failing Fast thực sự rất dễ hiểu. Khi không còn cách nào khác để bạn có thể làm theo một method thì hãy Fail.

🔴 Dirty

private void LoginUser(string phone, String password) {
   if(!string.IsNullOrWhiteSpace(phone))
   {
       if(!string.IsNullOrWhiteSpace(password)) {
           // login user here
       }
       else 
       {
           throw new ArgumentException("Password is required.");
       }
   }
   else {
       throw new ArgumentException("Phone is required.");
   }
}

Fail trong trường hợp này có nghĩa là bạn sẽ đưa ra một ngoại lệ (throw an exception) ngay khi một tình huống bất ngờ không thể xử lý được xảy ra, như bạn có thể thấy ở đây.

🟢 Clean

// Fail Fast
private void LoginUser(string phone, String password) {
   if(!string.IsNullOrWhiteSpace(phone)) throw new ArgumentException("Phone is required.");
   if(!string.IsNullOrWhiteSpace(password)) throw new ArgumentException("Password is required.");
       
   // login user here
}

Guard Clauses đảm bảo rằng dữ liệu đầu vào của phương thức là hợp lệ trước khi tiếp tục xử lý. Chạy tất cả các Guard Clauses và điều chỉnh sớm sẽ đơn giản hóa method bằng cách giảm thụt lề và do đó giảm độ phức tạp của chu trình. Guard Clauses hữu ích khi tạo contract ở đầu phương thức của bạn để làm rõ trạng thái mong đợi cho các tham số.

Trong ví dụ này, trường số điện thoại người dùng (phone) và mật khẩu (password) được mong đợi không phải là null hoặc empty. Vì vậy, thay vì thêm các cấp độ lồng nhau che giấu mục đích cốt lõi của phương pháp, chúng ta có thể đặt các kiểm tra an toàn ở đầu method và sau đó ngay lập tức throw exceptions nếu phát hiện trạng thái ngoại lệ. Nếu cần thực hiện nhiều lần kiểm tra trước khi thực hiện phần thân của method, việc sắp xếp lại các lần kiểm tra này ở đầu thành một method được đặt tên thậm chí có thể hữu ích.

Failing Fast cũng hữu ích khi làm việc với các câu lệnh switch. Mỗi switch phải có giá trị default để bạn biết khi nào switch rơi vào trạng thái không mong muốn. Ví dụ: Điều gì sẽ xảy ra trong ví dụ này nếu không có giá trị mặc định.

private void LoginUser(User user) {
   switch (user.Status)
   {
       case Status.Active:
           // logic for active users
           break;
       case Status.Inactive:
           // logic for inactive users
           break;
        case Status.Locked:
           // logic for locked users
           break;
         default:
           throw new ApplicationException("Unkown user status: ${user.Status}");
    }
}

Nếu ai đó cố gắng đăng nhập với tư cách là người dùng trong trạng thái không mong muốn, hệ thống sẽ không đăng nhập người dùng đó. Nhưng hệ thống cũng sẽ không đưa ra ngoại lệ (thrown an exception). Vì vậy, chúng ta có thể sẽ nhận được một vé sau đó từ người dùng phàn nàn về việc họ không thể đăng nhập và sẽ rất khó để tìm ra lý do tại sao vì hệ thống sẽ không đưa ra lỗi ở chỗ này và nó có thể hoặc có thể không bị lỗi ở nơi khác tùy thuộc vào cách cấu trúc mã liên quan.

Đây là Failing Slow và nó che giấu nguyên nhân gốc rễ và khiến việc gỡ lỗi trở thành một công việc điều tra khó khăn. Hãy chắc chắn rằng bạn Fail Fast.

Vì phần này khá dài, nên nó sẽ được chia nhỏ ra. Chúng ta sẽ đến tiếp với phần 2 Clean code #4: Viết các Hàm (Function) (P2)


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í