Chapter 7: Making Control Flow Easy to Read

Nếu code của bạn không có điều kiện, vòng lặp hay một vài câu lệnh phân chia luồng (control flow statements), nó sẽ rất dễ để đọc. Những lần nhảy code, chia nhánh làm code nhanh chóng trở nên khó đọc. Trong chương này, chúng ta sẽ tìm cách để viết các đoạn mã chia luồng dễ đọc hơn

K E Y I D E A
Make all your conditionals, loops, and other changes to control flow as “natural” as possible—written in a way that doesn’t make the reader stop and reread your code.

The Order of Arguments in Conditionals

Cái nào trong 2 đoạn mã này dễ đọc hơn

1
if (length >= 10)

hay

1
if (10 <= length)

Đa số lập trình viên, đoạn code đầu tiên dễ đọc hơn. Nhưng xem xét 1 dòng tiếp theo

1
while (bytes_received < bytes_expected)

hay
1
while (bytes_expected > bytes_received)

Thì ngược lại, phiên bản đầu tiên khó đọc hơn? Tại sao vậy? Liệu có một rule chung nào ở đâu? Làm sao bạn quyết định được khi nào tốt hơn khi viết a < a hay b > a?

Đây là 1 vài hướng dẫn chúng tôi tìm được hữu ích

Left-hand side Right-hand side
The expression “being interrogated,” whose value is more in flux. The expression being compared against, whose value is more constant.

Left-hand side: Biểu thức “đang được tra hỏi” có giá trị cao hơn giá trị thông lượng (giá trị chung)
Right-hand side: Biểu thức được so sánh với giá trị không đổi.

Hướng dẫn này trùng với việc sử dụng tiếng Anh - nó giống với ngôn ngữ nói tự nhiên: “nếu bạn kiếm được ít hơn 100$/năm” hoặc “nếu bạn nhỏ hơn 18 tuổi”. Sẽ không thuận tai lắm khi nói “nếu 18 ít hơn tuổi của bạn”.

Điều này giải thích tại sao while (bytes_received < bytes_expected) là dễ đọc hơn. bytes_received là giá trị chúng ta đang kiểm tra, và nó tăng lên sau mỗi vòng lặp. bytes_expected là 1 giá trị “cố định” để so sánh.

“YODA NOTATION”: STILL USEFUL?

Trong 1 vài ngôn ngữ (bao gồm C và C++ nhưng không có Java), bạn có thể đặt một phép gán trong 1 điều kiện:

1
if (obj = NULL) ...

Rất có thể đây là 1 bug, và cái mà lập trình viên thực sự muốn sẽ là
1
if (obj == NULL) ...

Để chặn những bug kiểu này, rất nhiều lập trình viên thay đổi thứ tự đối số như:
1
if (NULL == obj) ...

Cách này, nếu == là vô tình viết thành =, biểu thức if (NULL = obj) sẽ báo lỗi

Không may, đổi thứ tự làm code có 1 chút không tự nhiên (Như Yoda sẽ nói “Không có gì để nói về nó”). May mắn thay, các trình biên dịch hiện tại đã có thể đưa ra các cảnh báo if (obj = NULL) do đó “Yoda Notation” trở thành 1 phần của quá khứ.

The Order of if/else Blocks

Khi bạn viết 1 đoạn mã if/else, bạn thường luôn luôn tự do để thay đổi thứ tự của các khối. Ví dụ bạn có thể viết

1
2
3
4
5
if (a == b) {
// Case One ...
} else {
// Case Two ...
}

Hoặc
1
2
3
4
5
if (a != b) {
// Case Two ...
} else {
// Case One ...
}

Bạn có thể không nghĩ nhiều về điều này trước đó, nhưng trong 1 vài trường hợp, có 1 số lý do để ưu tiên 1 thứ tự sắp xếp hơn:

  • Ưu tiên xử lý với positive case đầu tiên thay vì negative. Ví dụ if(debug) thay vì if (!debug)
  • Ưu tiên xử lý với các trường hợp simpler case - đơn giản hơn đầu tiên để đưa nó ra khỏi đường đi. Cách tiếp cận này cũng có thể cho phép cả if và else hiển thị trên màn hình cùng 1 lúc, điều này thì thật tuyệt.
  • Ưu tiên xử lý với các trường hợp cần quan tâm hơn hoặc dễ thấy hơn trước.

Một vài tham chiếu ở trên có thể mẫu thuẫn và bạn cần xem xét gọi cái nào. Nhưng trong rất nhiều trường hợp, có 1 lựa chọn sẽ chiến thắng rõ ràng.

Ví dụ giả định bạn có 1 web server và xây dựng 1 response dựa vào cái mà URL chứa các tham số truy vấn expand_all:

1
2
3
4
5
6
7
8
9
if (!url.HasQueryParameter("expand_all")) {
response.Render(items);
...
} else {
for (int i = 0; i < items.size(); i++) {
items[i].Expand();
}
...
}

Khi mà nguowifd dọc lướt qua từ lần đầu, não của anh ấy lập tức nghĩ về trường hợp expand_all. Nó giống như khi ai đó nói “Đừng nghĩ về 1 con voi màu hồng”. Bạn không thể giúp nhưng nghĩ về nó - the “don’t” bị nhấn chìm bởi một sự khác thường “con voi màu hồng”.

Ở đây, expand_all là con voi màu hồng của chúng ta. Bởi vì nó rơi vào trường hợp đang được chú ý (và nó cũng là positive case), hãy xử nó đầu tiên:

1
2
3
4
5
6
7
8
9
if (url.HasQueryParameter("expand_all")) {
for (int i = 0; i < items.size(); i++) {
items[i].Expand();
}
...
} else {
response.Render(items);
...
}

Mặc khác, đây là trường hợp mà case phủ định đơn giản hơn và quan tâm/nguy hiểm hơn, do đó chúng ta xử nó đầu tiên:
1
2
3
4
if not file:
# Log the error ...
else:
# ...

Một lần nữa, tùy thuộc vào các chi tiết, đây có thể là 1 sự xem xét.

Tóm lại, lời khuyên của chúng tôi là đơn giản chú ý đến các yếu đố này và xem xét các trường hợp if/else của bạn đang ở 1 trật tự khó xử.

The ?: Conditional Expression (a.k.a. “Ternary Operator”)

Trong ngôn ngữ như C, bạn có thể viết biểu thức điều kiện như cond ? a : b cái mà tương đương với cách viết (cond) { a } else { b }.

Ảnh hưởng của nó đến khả năng đọc code thì gây tranh cãi. Người đề xướng thì nghĩ nó đẹp khi viết 1 cái gì đó trong 1 dòng thay vì nhiều dòng. Những người phản đối thì cho rằng nó có thể gây nhầm lẫn khi đọc và có thể khó để debug.

Đây là ví dụ các biểu thức này có thể đọc và ngắn gọn:

1
time_str += (hour >= 12) ? "pm" : "am";

Thay vì chúng ta phải viết:
1
2
3
4
5
if (hour >= 12) {
time_str += "pm";
} else {
time_str += "am";
}

Tuy nhiên, đôi khi chính cách viết này lại rất khó để đọc
1
return exponent >= 0 ? mantissa * (1 << exponent) : mantissa / (1 << -exponent);

Ở đây các biểu thức điều kiện không chỉ còn là sự lựa chọn giữa 2 giá trị đơn thuần. Động lực để viết code như vậy chỉ là “cố gắng nhồi nhét mọi thứ trên 1 dòng”.

K E Y I D E A
Instead of minimizing the number of lines, a better metric is to minimize the time needed for someone to understand it.

Hãy trả đoạn code trên về tự nhiên:

1
2
3
4
5
if (exponent >= 0) {
return mantissa * (1 << exponent);
} else {
return mantissa / (1 << -exponent);
}

A D V I C E
By default, use an if/else. The ternary ?: should be used only for the simplest cases.

Avoid do/while Loops

Rất nhiều các ngôn ngữ lập trình được tôn trọng, như Perl, có 1 vòng lặp do { expression } while (condition). expression ở đây thực thi ít nhất 1 lần. Và đây là ví dụ:

1
2
3
4
5
6
7
8
9
10
11
// Search through the list, starting at 'node', for the given 'name'.
// Don't consider more than 'max_length' nodes.
public boolean ListHasNode(Node node, String name, int max_length) {
do {
if (node.name().equals(name))
return true;
node = node.next();
} while (node != null && --max_length > 0);

return false;
}

Điều kì lạ về một vòng lặp do/while là đoạn code có thể hoặc không thể thực thi lại dựa vào điều kiện bên dưới của nó. Vì bạn thường đọc code từ trên xuống dưới, điều này khiến do/while có 1 chút không được tự nhiên. Một vài người đọc cuối cùng đã đọc code tới 2 lần.

Vòng lặp while thì dễ dàng đọc hơn vì bạn biết các điều kiện cho tất cả vòng lặp trước khi bạn đọc code bên trong. Nhưng nó cũng có thể ngớ ngẩn khi lặp code chỉ để xóa do/while:

1
2
3
4
5
6
// Imitating a do/while — DON'T DO THIS!
body

while (condition) {
body (again)
}

May mắn thay, chúng ta đã tìm được 1 cách phổ biến đề đa số vòng lặp do/while có thể được viết như là vòng lặp while:
1
2
3
4
5
6
7
public boolean ListHasNode(Node node, String name, int max_length) {
while (node != null && max_length-- > 0) {
if (node.name().equals(name)) return true;
node = node.next();
}
return false;
}

Phiên bản này cũng mang lại những vợi ích khi vẫn làm việc nếu max_length là 0 hoặc nếu node là null.

Một vài lí do khác để tránh sử dụng do/while là câu lệnh continue có thể gây nhầm lẫn bên trong nó. Ví dụ, đoạn code sau làm gì?

1
2
3
do {
continue;
} while (false);

Nó lặp mãi mãi hay chỉ 1 lần. Đa số các lập trình viên phải dừng và nghĩ về nó (Nó nên lặp lại 1 lần)

Nhìn chung, Bjarne Stroustrup, người tạo ra C++ nói rằng nó tốt nhất (trong cuốn The C++ Programming Language)):

In my experience, the do-statement is a source of errors and confusion. … I prefer the condition “up front where I can see it.” Consequently, I tend to avoid do-statements

Returning Early from a Function

Một vài coders tin rằng hàm không bao giờ nên có nhiều trạng thái return. Điều này vô lý. Trả về sớm nhất từ 1 hàm thì hoàn toàn tốt - và thường được mong muốn. Ví dụ:

1
2
3
4
5
public boolean Contains(String str, String substr) {
if (str == null || substr == null) return false;
if (substr.equals("")) return true;
...
}

Thực thi hàm này mà không vào những “mệnh đề bảo vệ” này sẽ rất không tự nhiên

Một trong những sự giải thích muốn 1 điểm thoát để tất cả sự dọn dẹp mã ở cuối cùng của hàm được đảm bảo được gọi. Nhưng các ngôn ngữ hiện đại cung cấp nhiều cách tinh vi hơn để đạt được sự đảm bảo này:

Language Structured idiom for cleanup code
C++ destructors
Java, Python try finally
Python with
with using

Chia buồn với C, không có cơ chế kích hoạt mã cụ thể khi hàm exit. Do đó nếu 1 hàm với với rất nhiều sự dọn dẹp code, trả về sớm nhất có thể khó để thực hiện chính xác. Trong trường hợp này, một tùy chọn khác bao gồm tái cấu trúc lại hàm này hoặc thậm chí khôn ngoan hơn là sử dụng goto cleanup;

The Infamous goto

Trong các ngôn ngữ khác C, sẽ ít khi cần goto vì có nhiều cách tốt hơn để làm tốt việc hoàn tất. gotos cũng rõ ràng nhanh chóng để code ra khỏi tầm tay và làm code khó để theo dõi hơn.

Nhưng bạn có thể vẫn nhìn thấy goto trong 1 vài projects C - đáng chú ý nhất trong Linux kernel. Trước khi bạn bỏ qua tất cả sử dụng, sẽ rất hữu ích phân tích lý do tại sao sử dụng goto lại tốt hơn 1 vài cách khác.

Đơn giản nhất, rất ngây thơ sử dụng goto với 1 exit đơn ở cuối hàm:

1
2
3
4
5
6
7
    if (p == NULL) goto exit;
...
exit:
fclose(file1);
fclose(file2);
...
return;

Nếu đây là chỗ duy nhất mà goto được phép, goto sẽ không thành vấn đề.

Vấn đề xuất hiện khi có nhiều mục tiêu đến goto, đặc biệt là trên những con đường mà nó qua. Đặc biệt, goto có thể tạo thành các đoạn code “mỳ ống” thực sự, và chúng chắc chắn có thể được thay thể bằng cách vòng lặp có cấu trúc. Trong hầu hết thời gian, goto nên được tránh sử dụng.

Minimize Nesting

Code với các phần lồng nhau sâu rất khó để hiểu. Mỗi tầng của vòng lồng nhau thêm 1 điều kiện vào “ngăn xếp tinh thần” của người đọc. Khi người đọc nhìn thấy dấu đóng đúng (})) nó có thể gây khó khăn để “pop” ra khỏi ngăn xếp và nhớ điều kiện nào ở bên dưới.

Đây là 1 ví dụ tương đối đơn giản - nếu bạn tự nhận thấy bạn phải nhìn lên xem các điều kiện có bị chặn không?

1
2
3
4
5
6
7
8
9
10
11
if (user_result == SUCCESS) {
if (permission_result != SUCCESS) {
reply.WriteErrors("error reading permissions");
reply.Done();
return;
}
reply.WriteErrors("");
} else {
reply.WriteErrors(user_result);
}
reply.Done();

Khi bạn nhìn dấu đóng đầu tiên, bạn phải tự nghĩ, Oh, permission_result != SUCCESS đã vừa xong, bây giờ permission_result == SUCCESS và điều này nằm trong khối với user_result == SUCCESS.

Nhìn chung, bạn phải giữ giá trị user_resultpermission_result trong đầu ở mọi lúc. Và mỗi khối if { } đóng lại, bạn phải chuyển đổi giá trị tương ứng trong suy nghĩ của mình.

Đoạn mã này thậm chí còn tồi tệ hơn vì nó cứ xem kẽ giữa trường hợp SUCCESS và non-SUCCESS.

How Nesting Accumulates

Trước khi bạn thử sửa code ví dụ trước đó, hãy nói về cách nó sẽ kết thúc. Ban đầu, code sẽ đơn giản:

1
2
3
4
5
6
if (user_result == SUCCESS) {
reply.WriteErrors("");
} else {
reply.WriteErrors(user_result);
}
reply.Done();

Đoạn code này quá dễ hiểu. Nhưng lập trình viên đã thêm toán tử thử 2 vào:
1
2
3
4
5
6
7
8
9
if (user_result == SUCCESS) {
if (permission_result != SUCCESS) {
reply.WriteErrors("error reading permissions");
reply.Done();
return;
}

reply.WriteErrors("");
...

Sự thay đổi này có ý nghĩa - lập trình viên có 1 khối code chèn vào và cô ấy tìm nơi dễ dàng nhất để chèn nó. Đoạn code mới này rất mới mẻ và “in đậm” và tinh thần của cô ấy. Và “sự khác biệt” này rất rõ ràng - nó là 1 sự thay đổi đơn giản.

Nhưng khi 1 ai đó đọc code sau đó, tất cả bối cảnh đó đã biến mất. Đây là cách khi bạn lần đầu nìn vào code từ bắt đầu phần này - bạn đã nhận được tất cả code trong cùng 1 lúc.

K E Y I D E A
Look at your code from a fresh perspective when you’re making changes. Step back and look at it as a whole.

Removing Nesting by Returning Early

Okay, hãy cải tiến đoạn code này. Code lồng nhau có thể bị xóa đi bằng cách xử lý “trường hợp lỗi” sớm nhất có thể và trả về sớm nhất kết quả từ hàm:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (user_result != SUCCESS) {
reply.WriteErrors(user_result);
reply.Done();
return;
}
if (permission_result != SUCCESS) {
reply.WriteErrors(permission_result);
reply.Done();
return;
}

reply.WriteErrors("");
reply.Done();

Code giờ chỉ còn 1 level lồng nhau thay vì 2 như trước. Nhưng điều quan trọng nhất, người đọc không còn phải “pop” bất cứ gì từ trong stack tinh thần của họ - mỗi khối if kết thúc với 1 return rồi.

Removing Nesting Inside Loops

Kĩ thuật trả về sớm không phải lúc nào cũng được áp dụng. Ví dụ, đây là trường hợp code trong vòng lặp:

1
2
3
4
5
6
7
8
9
10
for (int i = 0; i < results.size(); i++) {
if (results[i] != NULL) {
non_null_count++;
if (results[i]->name != "") {
cout << "Considering candidate..." << endl;

...
}
}
}

Bên trong vòng lặp, kỹ thuật tương tự với return sớm và continue
1
2
3
4
5
6
7
8
9
for (int i = 0; i < results.size(); i++) {
if (results[i] == NULL) continue;
non_null_count++;

if (results[i]->name == "") continue;
cout << "Considering candidate..." << endl;

...
}

Nhìn chung, continue cũng có thể gây hiểu nhầm, vì nó dẫn người đọc đi 1 vòng tương tự goto. Nhưng trong trường hợp này, mỗi vòng lặp độc lập nhau, do vậy mà người đọc có thể dễ dàng nhìn thấy continue chỉ có nghĩa là “bỏ qua các item này”.

Can You Follow the Flow of Execution?

Chương này đã nói về low-level của điều khiển luồng: làm sao để tạo ra các vòng lặp, điều kiện và jumps code khác dễ đọc. Nhưng bạn cũng nên nghĩ về “flow” trong chương trình của chúng ta ở mức high level.
Lý tưởng nhất, nó nên dễ dàng để theo dõi toàn bộ luồng thực hiện trong chương trình - bạn đã bắt đầu ở main() và nghĩ rằng qua từng dòng cả code, như 1 hàm gọi hàm khác cho đến khi chương trình kết thúc.

Tuy nhiên, trong thực tế, các ngôn ngữ lập trình và thư việc có cấu trúc làm cho code thực thi “behind the sences” hoặc làm cho nó khó có thể theo dõi. Đây là 1 vài ví dụ

Programming construct How high-level program flow gets obscured
threading It’s unclear what code is executed when.
signal/interrupt handlers Certain code might be executed at any time.
exceptions Execution can bubble up through multiple function calls.
function pointers & anonymous functions It’s hard to know exactly what code is going to run because that isn’t known at compile time.
virtual methods object.virtualMethod() might invoke code of an unknown subclass.

Một vài cấu trúc này rất hữu ích và chúng thậm chí còn làm code của bạn dễ đọc hơn và code ít bị dư thừa. Nhưng đối với lập trình viên, đôi khi họ sử dụng nó 1 cách quá mức mà không nhận ra nó đã gây khó khăn cho người đọc như thế nào để hiểu code sau đó. Và các cấu trúc nào cũng tạo ra các lỗi rất khó để truy xuất.

Điều quan trọng nhất là không nên để quá nhiều phần trăm code của bạn sử dụng các cấu trúc này. Nếu bạn lạm dụng những tính năng này, nó có thể làm đoạn mã của bạn được xem như 1 trò chơi Three-Card Monte (như trong phim hoạt hình)

Phần này tôi đang hiểu ý muốn nói phần thiết kế code chung cũng nên tránh sử dụng quá nhiều các cấu trúc phức tạp từ bảng trên, vì như vậy code của bạn sẽ khó mà theo dõi. Nếu sử dụng nhiều quá, nó sẽ như trò tráo 3 lá bài ý @@, lằng nhằng lắm :v.

Túm lại

Có một vài thứ bạn có thể làm để code kiểm soát luồng dễ dàng đọc hơn

Khi bạn viết 1 phép so sánh (while (bytes_expected > bytes_received)), tốt hơn là bạn nên để các giá trị thay đổi sang bên trái và những giá trị cố định sang bên phải (while (bytes_received < bytes_expected)).

Bạn cũng có thể sắp xếp lại các khối trong câu lệnh if/else. Nói chung, cố gắng thử xử lý trường hợp positive/easier/interesting đầu tiên. Đôi khi những tiêu chí này mâu thuẫn, nhưng sẽ có 1 nguyên tắc vượt trội hơn.

Một số cấu trúc lập trình như toán tử điều hành (:?), do/while, và goto thường gây ra kết quả là code khó đọc. Tốt nhất là không nên sử dụng chúng, những sự thay thế rõ ràng hơn luôn tồn tại.

Các khối code lồng nhau yêu cầy nhiều sự tập trung hơn để theo dõi xuyên suốt. Mỗi đoạn lồng như vậy yêu cầu nhiều ngữ cảnh và “đẩy nó vào trong ngăn xếp tinh thần” của người đọc. Thay vào đó, tùy chọn code “tuyến tính” sẽ tránh được các sự lồng nhau này.

Trả về sớm có thể tránh được các sự lồng nhau và dọn dẹp code nói chung. “Guard statements” (xử lý các trường hợp đơn giản ở đầu các hàm như check null, check empty các thứ ấy) đặc biệt hữu ích.

Author

Ming

Posted on

2020-05-24

Updated on

2021-04-10

Licensed under

Comments