+9

Những nguyên tắc đảm bảo tính dễ đọc của method (Cyclomatic Complexity) - dành cho người mới lập trình

Nguồn : http://qiita.com/hirokidaichi/items/c9a76191216f3cc6c4b2

Người dịch : Phan Hoàng Minh

Tôi muốn nói điều gì?

Đây là những gì tôi biên tập lại từ các đề tài nghiên cứu dành cho người mới.

Những gì viết ở đây không phải là tuyệt đối. Một điều rất quan trọng các bạn cần nhớ là tùy project, tùy team, tùy ngôn ngữ mà cách viết code sao cho dễ đọc sẽ khác nhau. Các bạn hãy sử dụng bài viết này như một cơ sở để tranh luận thôi.

Ngoài ra, tôi cũng đề cập đến khá nhiều ngôn ngữ khác nhau trong bài viết nên trong khi đọc mong các bạn hãy liên tưởng đến ngôn ngữ mình đang làm.

Nguyên tắc

Nguyên tắc để viết 1 method sao cho đẹp là 5+1 như dưới đây.

  • Tối giản số loại toán tử của phép toán logic (logical operator)
  • Tối giản số lượng block nest
  • Tối giản phạm vi hiệu lực (scope) của biến
  • Tối giản việc gán giá trị cho biến
  • Tối giản số lượng các vòng lặp không mang tính chất giải thích
  • Check tất cả những điều trên một cách tự động

Tối giản số loại toán tử của phép toán logic

Chẳng hạn, với 1 lệnh điều kiện mà tôi dùng quá nhiều toán tử như &&, || thì sẽ khiến câu lệnh ấy rất khó hiểu. Tất nhiên là trong trường viết code từ đầu thì nhiều người có thể ý thức được điều đó , nhưng trong trường hợp thêm điều kiện vào 1 hàm có sẵn thì do tâm lí không muốn sửa lại các điều kiện đã có sẵn sẽ dẫn đến việc chúng ta làm tăng số lượng toán tử.

#Chỉ nên dùng 1 loại toán tử
if( A and B) {
}

Chỉ cần có thêm 2 toán tử trở lên như dưới đây thì đột nhiên code trở nên rất khó đọc.

if( A && B || C ){

}

if( not A or not B) ){`

}

Ngoài ra, độ ưu tiên của toán tử trong từng ngôn ngữ cũng khác nhau và đây chính là nguyên nhân làm phát sinh nhiều bug.

Quy tắc De Morgan

1 cách đơn giản để tối giản số lượng toán tử là sử dụng quy tắc De Morgan.

http://ja.wikipedia.org/wiki/ド・モルガンの法則

if( !A or !B ){}
if( !A and !B and !C and !D){}

Câu lệnh này có thể viết lại như dưới đây.

unless( A and B ){}
unless( A or B or C or D ){}

Làm vậy sẽ khiến toán tử not mất đi và code dễ đọc hơn.

Khi ta đọc code này, ta sẽ hiểu mà không cần phải nghĩ ngợi nhiều và điều đó sẽ khiến tính ứng dụng của code tăng lên.

Mọi người vẫn thường nghĩ cách làm đơn giản như sau sễ khiến code dễ hiểu : ta có 1 câu điều kiện if(!A) và ta muốn thêm !B vào trong câu đó => ta hay viết (if(tất cả các điều kiện trước đó) or !B).

Đây là một sai lầm. Trong trường hợp này, người đang viết có thể thấy dễ hiểu nhưng người đọc sau này thì chắc là không.

Vì vậy ở đây, chúng ta nên tư duy theo quy tắc De Morgan : nếu ngôn ngữ chúng ta đang làm cho phép sử dụng lệnh phủ định như unless thì chúng ta nên dùng. Đó mới là cách làm thông minh.

Chia nhỏ các method

Chúng ta có 1 đoạn code như dưới đây.

if( (A && B) || (C && D)){
    :
    : Related case
    return result
}
    :
    : Main process
    :
return result

Chúng ta có thể cắt riêng phần Related case ra để nó trở thành 1 đoạn code dễ đọc như dưới đây.

return this.subMethod() if( A and B );
return this.subMethod() if( C and D );
:
: Main process
:
return result

Bằng cách sử dụng khéo léo lệnh if, lệnh unless ta có thể chia nhỏ các process cũng như tách riêng các case nối với nhau bởi toán tử or.

Từ một process cho đến một function chúng ta đều có thể chia nhỏ các method, viết unit test riêng cho các phần đã chia nhỏ đó => đây là kĩ năng Sprout method : giúp cho các method luôn tươi mới và có tính ứng dụng cao.

Sử dụng biến số mang tính chất giải thích

Nếu câu điều kiện phức tạp hơn mức cần thiết thì ta có thể thêm biến số mang tính giải thích như dưới đây. Điều này cũng khiến code trở nên dễ đọc hơn.

#Biến số mang tính chất giải thích
var isHeapOverCapacity = (A and B) or (C and D);
if( isHeapOverCapacity ){

}

Chia nhỏ method/class (2)

Nếu có câu điều kiện phức tạp dùng ở nhiều chỗ, thì việc cần làm là chia nhỏ chúng ra bằng các method và class.

Chẳng hạn đối với đoạn code dưới đây.

if( int(status/100) == 4 || int(status/100) == 5 || err != null) {

}

chúng ta viết lại thành

if( this.hasError() ){

}

hoặc

if( response.hasError() ){

}

Việc chia nhỏ thành các method hoặc hàm số có tính chất giải thích như vậy sẽ khiến người đọc hiểu ngay là ta viết đoạn code đó để làm gì.

Mặt khác, nếu ta chia nhỏ thành các hàm số rồi thì các toán tử || sẽ có thể được biểu diễn thành một hàng dọc như sau.

function hasError(){
    return isClientError(status) or
        isServerError(status) or
        ( this.err != null );

}

function hasError(){
    return true if isClientError(status);
    return true if isServerError(status);
    return (this.err != null ):
}

Đối với process phức tạp, ta có thể viết các lệnh if thẳng hàng như trên cho dễ hiểu.

Tối giản số lượng block nest

Block nest nghĩa là những phần code giới hạn trong kí tự {} tạo ra bởi lệnh if hoặc lệnh for.

if(){ // 1
    for(){ // 2
        for(){ //3
            if(){ //4
                :
            }
            if(){
                :
            }
            switch(){
                :
            }
        }
    }
}

Chẳng hạn trong trường hợp trên, số lượng block nest nhiều khiến code trở nên rất khó đọc.

Cách phòng tránh

Cách đơn giản nhất để tối giản số lượng block nest là sửa chính câu lệnh if ở ngoài cùng. Chẳng hạn như đoạn code dưới đây.

if(){
 ...main process...
}
throw Error;

ta có thể sửa thành

throw Error unless();
 ...main process...

Thay vì cấu trúc control, ta sử dụng cấu trúc data

Khi xử lí trong code quá phức tạp, ta có thể đơn giản code bằng cách đơn giản hóa cấu trúc data.

Dispatch table

Đối với code như dưới đây

#Switch trong vòng lặp for
for(var i=0;i<array.length;i++){
    switch(array[i]){
        case ‘a’:doSomethingForA();break;
        case ‘b’:doSomethingForB();break;
                :
                :
        default:doDefault();
    }
}

ta có thể đưa dispatch table vào trong lệnh switch như sau để khiến code đơn giản hơn.

var dispatchTable = {
    a : doSomethingForA,
    b : doSomethingForB
                :
};

for(var i=0;i<array.length;i++){
    (dispatchTable[array[i]] || doDefault)();
}

Bằng cách đổi cấu trúc control thành cấu trúc data như vậy, code sẽ được đơn giản hóa.

Tạo tích Đề-các của 2 tập hợp

Ví dụ trong trường hợp dưới đây :

#2 vòng lặp for lồng vào nhau
tx = %w(1 2 3 4 5)
ty = %w(a b c d e)

for x in tx
    for y in ty
        p([x,y])
    end
end

Ta có thể thấy câu lệnh for bị sử dụng 2 lần.

Bằng cách tạo các tổ hợp (1 a)(1 b) ..(1 e)(2 a)..(5..e) ta có thể viết lại câu lệnh for.

#Tạo sẵn tích đề các
tx = %w(1 2 3 4 5)
ty = %w(a b c d e)

for point in tx.product(ty)
    p(point)
end

Như vậy ta đã làm giảm số block nest đi theo câu lệnh for.

Nếu việc diễn giải ra như trên dẫn đến vấn đề về performance hoặc break code giữa chừng thì ta có thể tạo tích Đề-các cho 2 tập hợp như sau.

for point in tx.lazy.flat_map{|x| ty.lazy.map{|y|[x,y]} }
p(point)
end

Khi ta sử dụng flat_map để tính tích Đề-các, trong trường hợp scala ta có thể viết như dưới đây.

var tx = ( 0 to 5 )
var ty = (‘a’ to ‘e’)
for(i <- tx;j <- ty ) println(i,j)

Lệnh for ở đây được dùng như 1 syntactic sugar dành cho tích Đề-các.

Trường hợp nên và không nên sử dụng cấu trúc data thay cho cấu trúc control

Không phải lúc nào ta cũng nên thay cấu trúc control bằng cấu trúc data.

Một cách giải quyết đơn giản hơn chính là chia nhỏ cấu trúc control thành các hàm và method riêng biệt. Tuy nhiên, ta cần suy nghĩ về tính ứng dụng, tính mở rộng của code và tìm ra những điểm chắc chắn liên quan đến chuyện đó, để chuyển hóa chỉ những điểm đó thôi thành cấu trúc data. Nếu nó quá phức tạp, ta hãy chia nó thành class – như vậy mới là tối ưu.

Know-how liên quan đến cấu trúc data hay cấu trúc class được tổng hợp thành GOF design pattern mà các bạn có thể tham khảo theo link dưới đây. http://en.wikipedia.org/wiki/Software_design_pattern

Chain of responsibility pattern, Command pattern, Iterator pattern, Visitor pattern,... vân vân được viết theo những ví dụ rất dễ hiểu.

Tối giản phạm vi hiệu lực (scope) của biến

Định nghĩa phạm vi hiệu lực của biến :

http://en.wikipedia.org/wiki/Scope_(computer_science)

Khi hàm của chúng ta dài ra thì sẽ có rất nhiều biến được khai báo ở những chỗ khác nhau.

function XXX(){
    var x =;
    var y,z =,.;

        :

    var p,q,r =,,;

        :
        :

}

Nếu mà như thế này thì việc chia nhỏ các process sẽ rất khó vì chúng ta cứ phải rà soát xem có giá trị biến nào bị sửa không.

Chẳng hạn, ta thử luận về trường hợp biến count như dưới đây.

var count = 0;
function counter(){
    return count++;
}

Về bản chất, ta không muốn người khác đụng vào biến count nhưng ta lại đặt scope của nó rất rộng – bên ngoài function.

Vì thế giả sử nếu có ai đó sửa count = 10 chẳng hạn thì tất cả những hàm dùng biến count sẽ lỗi hết.

Thay vì vậy, ta nên tạo scope và giới hạn phạm vi hiệu lực của biến count như dưới đây.

var counter = (function(){
    var count = 0;
    return funtion(){return count++}
})();

Việc chia nhỏ code thành các method cũng là cách để khiến phạm vi hiệu lực của biến được thu lại.

Tối giản việc gán giá trị cho biến

Biến số sẽ rất khó theo dõi nếu ta cứ nhiều lần thay đổi giá trị của nó.

Chẳng hạn như ví dụ dưới đây.

11-300x191.png

Trong 1 method có đến 4 lần giá trị biến thay đổi như trên sẽ dẫn đến ý nghĩa và tính năng của biến đó thay đổi liên tục. Vì thế, người đọc khó mà hiểu được tình trạng code hiện tại => việc ứng dụng, mở rộng code rất khó.

Đối với từng thay đổi như trên, chỉ cần ta thêm biến mang tính chất giải thích vào sẽ khiến code dễ đọc hơn nhiều.

Sử dụng phép toán 3 thành phần (ternary operation) hay câu lệnh if trả về giá trị?

Việc gán giá trị cho biến như dưới đây có thể được chấp nhận trong nhiều trường hợp.

var result;
if(isActive){
    result =ACTIVE;
}else {
    result =INACTIVE;
}

Chúng ta hãy cùng xem thử code sau đây.

Bởi vì mục đích của code là trả giá trị result

var result = (isActive)?ACTIVE:INACTIVE;

nên ta viết như bên trên sẽ dễ hiểu hơn nhiều.

Phép toán 3 thành phần thường được cho là khó hiểu nhưng chỉ đối với trường hợp điều kiện thật phức tạp thôi. Nếu điều kiện đã phức tạp thì kiểu gì cũng khó hiểu, thế nên trong trường hợp này ta dùng phép toán 3 thành phần thay vì câu lệnh if lại làm đơn giản hóa code.

Đối với ngôn ngữ như Ruby thì bản thân câu if đã mang giá trị trả về => nếu như vậy thì ta nên sử dụng.

def status(isActive)
    if isActive
        :ACTIVE
    else
        :INACTIVE
end
end

Tối giản số lượng những vòng lặp không mang tính chất giải thích

Đối với những ngôn ngữ mới ra gần đây, 1 cách xử lí thường thấy đối với list là dùng một cái library nào đó. Mặc dù sử dụng vòng lặp for với điều kiện là 3 statement là chuyện bình thường nhưng thật ra là rất khó hiểu.

Đối với những trường hợp không ảnh hưởng đến performance, chúng ta nên dùng những xử lí có tính chất rõ ràng, minh bạch.

Mặt khác, những hệ thống theo kiểu foldLeft,reduce (lấy ra 1 giá trị từ list) sẽ giúp chúng ta giảm số lần gán giá trị cho biến.

var sum = 0;

for(var i=0,l=numbers.length;i<l;i++){
    if(numbers[i]%2 == 1 ) continue;
    sum += numbers[i]
}

Chẳng hạn như code trên có thể được viết thành như sau :

var sum = numbers.filter(isOdd).sum();

và sẽ khiến số lần gán giá trị cho biến giảm đi, ý nghĩa cũng rất rõ ràng.

numbers = (1..10)
def odd?(n)
    (n % 2 == 1)
end
p numbers.select(&method(:odd?)).inject(:+)

Ngoài ra những xử lí có bản chất là vòng lặp như all,any,none,include cũng khiến ta giảm được số lượng vòng lặp cho code, giúp code dễ hiểu hơn.

Check tất cả những điều trên một cách tự động

Nếu bạn thực hiện tất cả những gì tôi đã nói ở trên thì chỉ số phức tạp trong code của bạn (Cyclomatic complexity – CC) sẽ giảm đi.

http://en.wikipedia.org/wiki/Cyclomatic_complexity

Giá trị này càng cao thì số lượng test case bạn phải làm càng nhiều.

Nếu bạn làm bằng JavaScript thì có thể trực tiếp kiểm tra luôn chỉ số CC trên trang jsmeter.

Các ngôn ngữ khác thì có thể đưa lên những CI như jenkins để tính toán.

Perl : Perl::Metrics::Lite

• Ruby : rubocop

• Java : checkstyle

Nói chung là bạn nên giữ chỉ số trong khoảng 10~15, đừng vượt quá.


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í