Nghệ thuật viết code đẹp - Phần III: Đơn giản, dễ đọc hoá biểu thức

Tiếp nối 2 phần Nghệ thuật viết code đẹp trước. Hôm nay mình xin được giới thiệu tới các bạn phần 3 của series với tựa đề Đơn giản, dễ đọc hoá biểu thức. Link 2 phần trước các bạn có thể tham khảo ở đây:

Nghệ thuật viết code đẹp - Phần I: Viết flow điều kiện và vòng lặp dễ hiểu Nghệ thuật viết code đẹp - Phần II: Nên viết comment như thế nào?

Mỗi lần đọc source code, chắc hẳn các bạn đều phải đối mặt với những biểu thức logic phức tạp. Nhiều khi nó phức tạp tới mức làm ta tốn cả ngày tìm hiểu để biết xem biểu thức đó kiểm tra điều kiện gì. Ngay cả khi có comment đi kèm thì lúc ta bắt tay vào sửa logic cũng phải vô cùng đau đầu mới đảm bảo được source code sau khi đã sửa đổi đáp ứng được yêu cầu mới, nhưng vẫn đảm bảo chạy đúng với các yêu cầu spec trước đó. Việc gặp phải một biểu thức logic phức tạp trong khi đang đọc flow của chương trình khiến cho quá trình đọc hiểu của chúng ta bỗng dưng bị tắc nghẽn lại. Là người Việt Nam sống ở đô thị lớn như Hà Nội hay Sài Gòn, ai chẳng trải nghiệm tắc đường, nhưng sự bế tắc mà lập trình viên gặp phải trong trường hợp deadline đang đuổi tới mông thì chắc còn mang lại sự tức giận, khó chịu hơn rất nhiều! =)) . Vừa code vừa lẩm bẩm chửi thề là chuyện bình thường (yaoming) Key

Với những biểu thức logic lớn và phức tạp, chúng ta cần chia nhỏ thành những phần đơn giản hơn

Nào chúng ta cùng đi hiện thực hoá key đó với 7 cách dưới đây

1. Sử dụng biến thuyết minh

Cách đơn giản nhất để chia nhỏ biểu thức phức tạp là sử dụng biến mới để lưu giá trị trung gian. Biến này được gọi là Biến thuyết minh vì việc thêm biến số đó vào giúp việc đọc hiểu biểu thức logic được nhanh và người đọc cũng hiểu rõ ý đồ của người viết code hơn. Chúng ta cùng tham khảo ví dụ dưới đây:

if line.split(':')[0].strip() == "root":
    ...

Mới nhìn qua khó mà biết sau if người ta đang muốn check cái gì? Nếu sử dụng biến thuyết minh chúng ta sẽ có đoạn code mới như sau:

username = line.split(':')[0].strip()
if username == "root":
    ...

2. Sử dụng biến tóm lược

Đôi khi với những biểu thức không phức tạp, chúng ta cũng nên sử dụng thêm biến vào để việc nắm bắt và sửa code được nhanh hơn. Chúng ta hãy cùng xem xét ví dụ sau đây:

if (request.user.id == document.owner_id) {
    // User có thể edit tài liệu này
}
...
if (request.user.id != document.owner_id) {
    // User chỉ có quyền đọc mà thôi
}

Đù đoạn check logic request.user.id == document.owner_id không phức tạp, nhưng với 5 biến số trong đó như vậy người đọc vẫn phải định thần mất một chút mới hiểu được. Nếu chúng ta thêm một biến tóm lược vào thì sẽ nhanh hơn rất nhiều.

final boolean user_owns_document = (request.user.id == document.owner_id);

if (user_owns_document) {
    // User có thể edit tài liệu này
}
...
if (!user_owns_document) {
    // User chỉ có quyền đọc mà thôi
}

Biến user_owns_document được khai báo ngay trên đầu nên càng giúp cho việc đọc hiểu code phía sau được nhanh hơn.

3. Sử dụng định luật De Morgan

Định luật Demorgan

not (a or b or c) <=> (not a) and (not b) and (not c) not (a and b and c) <=> (not a) or (not b) or (not c)

Các bạn có thể nhớ định luật này một cách đơn giản là nếu có not ở ngoài thì bên trong or/and sẽ đảo nhau. Các bạn sẽ gặp nhiều bài toán mà nếu suy nghĩ theo hướng thông thường trong thực tế và viết biểu thức thì nhìn biểu thức đó sẽ rất phức tạp. Nhưng chỉ cần biến đổi đi một chút, trông biểu thức cũng đã gọn gàng hơn rất nhiều rồi. Hãy xem ví dụ dưới đây:

if (!(file_exists && !is_protected)) Error("Sorry, could not read file.");

Vận dụng định luật De Morgan ta có

if (!file_exists || is_protected)) Error("Sorry, could not read file.");

Biểu thức nhìn đã sáng sủa và dễ đọc hơn nhiều. (yeah3)

4. Tránh rút gọn quá mức biểu thức

Đọc lướt qua liệu các bạn có hiểu ngay ý nghĩa của biểu thức này không?

assert((!(bucket = FindBucket(key))) || !bucket->IsOccupied());

Còn nếu viết như thế này thì sao?

bucket = FindBucket(key);
if (bucket != NULL) assert(!bucket->IsOccupied());

Thông thường, việc viết cả biểu thức khó hiểu trên một dòng thường là để thể hiện cái tôi cá nhân, hay ngầm thể hiện rằng à đầu óc mình thông minh nên mới nghĩ ra được như vậy. Cũng có trường hợp người viết thích thú với thử thách tạo ra sự khác biệt, nhưng vô tình lại làm khó người đọc hiểu code về sau.

5. Thay đổi hướng tiếp cận với biểu thức phức logic phức tạp

Giả sử chúng ta đang implement một function trong struct Range dưới đây:

struct Range {
    int begin;
    int end;
    
    // Hàm kiểm tra xem có giao với vùng của range khác không
    bool OverlapsWith(Range other);
}

Đầu tiên chắc hẳn ta sẽ nghĩ tới case mà đầu của range này phải lớn hơn đầu của range kia, đồng thời phải nhỏ hơn đuôi của range ấy: Nên ta sẽ có biểu thức

begin >= other.begin && begin < other.end

Hoặc là end của range này phải lớn hơn begin của range kia và nhỏ hơn end của rang ấy: Ta lại có

end > other.begin && end <= other.end

Tương tự ta sẽ có hàm đầy đủ là:

bool OverlapsWith(Range other) {
    return (begin >= other.begin && begin < other.end) ||
                (end > other.begin && end <= other.end) ||
                (begin <= other.begin && end >= other.end);
}

Vâng, ở đây mình đã làm sẵn cho các bạn khâu đặt dấu = vào đâu cho hợp lý với < hay <=, > hay >= rồi. Vậy mà biểu thức nhìn vẫn khiến người ta hoa cả mắt. Đã vậy ai sẽ đảm bảo rằng các bạn đã care hết tất cả các case và sẽ ko có case nào cần check bị lọt??? Khi thấy một biểu thức phức tạp như vậy, là lúc chúng ta nên thay đổi cách tiếp cận vấn đề của mình. Thay vì tìm điều kiện để 2 vùng giao nhau, ta thử đặt giả thiết ngược lại, tìm điều kiện để 2 vùng không giao nhau trước. Với điều kiện ấy ta chỉ có 2 trường hợp phải xét tới!

  1. end của vùng này nhỏ hơn begin của vùng kia
  2. begin của vùng này lớn hơn end của vùng kia Khi đó hàm của chúng ta trở thành:
bool OverlapsWith(Range other) {
    if (other.end <= begin) return false;
    if (other.begin >= end) return false;
    return true;
}

Ngắn gọn, không sai sót và đảm bảo care hết tất cả các case.

6. Ngắn gọn hoá công thức

Hãy nhìn kĩ đoạn code sau:

void AddStats (const Stats& add_from, Stats* add_to) {
    add_to->set_total_memory(add_from.total_memory() + add_to->total_memory());
    add_to->set_free_memory(add_from.free_memory() + add_to->free_memory());
    add_to->set_swap_memory(add_from.swap_memory() + add_to->swap_memory());
    add_to->set_status_string(add_from.status_string() + add_to->status_string());
    add_to->set_num_process(add_from.num_process() + add_to->num_process());
    ...
}

Các bạn có thấy sự tương đồng không? Tương đồng nhưng trông rất rối mắt. Dạng tổng quát của các dòng lệnh là add_to->set_XXX(add_from.XXX() + add_to->XXX()); Thay vì viết đi viết lại n lần như vậy, trong C++ ta có thể sử dụng Macro để làm source code được ngắn gọn hơn như sau:

void AddStats (const Stats& add_from, Stats* add_to) {
    #define ADD_FIELD(field) add_to->set_##field(add_from.field() + add_to->field())
    ADD_FIELD(total_memory);
    ADD_FIELD(free_memory);
    ADD_FIELD(swap_memory);
    ADD_FIELD(status_string);
    ADD_FIELD(num_processes);
    ...
}

Mặc dù có nhiều khuyến cáo không nên lạm dụng Macro, tuy nhiên trong trường hợp này việc sử dụng nó giúp code của bạn nhìn đơn giản và đẹp mắt hơn rất nhiều. Cái lợi mà nó mang lại lớn hơn cái bất lợi khi sử dụng nó rất nhiều.

Tổng kết

Trên đây mình đã giới thiệu tới các bạn phần 3 của series Nghệ thuật code đẹp. Mong rằng những chia sẻ này sẽ giúp code các bạn viết ngày một đẹp hơn, và ít bị hậu bối chửi hơn (lol)