+1

Operator Precedence: Những Cái Bẫy Vô Hình Và Nghệ Thuật Dùng Dấu Ngoặc Đơn

Bất kỳ ai khi học lập trình đều thuộc lòng quy tắc toán học "Nhân chia trước, cộng trừ sau". Nhưng trong thế giới của Code, chúng ta không chỉ có cộng trừ nhân chia. Chúng ta có Bitwise (&, |, ^), Logical (&&, ||), Assignment (=), Ternary (?:), hay Dereference (*, ->).

Khi nhồi nhét tất cả chúng vào chung một biểu thức mà thiếu đi sự rào chắn cẩn thận, bạn đang tự tay gài một quả bom nổ chậm vào hệ thống. Hôm nay, chúng ta sẽ điểm mặt những cái bẫy Operator Precedence kinh điển nhất và cách phòng tránh.

1. Bản Chất: Precedence và Associativity

Khi trình biên dịch (Compiler) đọc một biểu thức, nó dựa vào 2 bộ quy tắc để quyết định thằng nào được chạy trước:

  1. Precedence (Thứ tự ưu tiên): Toán tử nào ở "bậc" cao hơn sẽ được gom nhóm để xử lý trước. (Ví dụ: * cao hơn +).
  2. Associativity (Tính kết hợp): Nếu các toán tử có cùng mức ưu tiên, ngôn ngữ sẽ tính từ Trái sang Phải (Left-to-Right) hay từ Phải sang Trái (Right-to-Left).
  • Ví dụ: Các phép toán số học thường là Left-to-Right (a - b - c = (a - b) - c). Nhưng phép gán = lại là Right-to-Left (a = b = c = a = (b = c)).

Hiểu lý thuyết là vậy, nhưng khi ra thực chiến, mọi thứ khốc liệt hơn nhiều.

2. Những Cái Bẫy "Đẫm Máu" Trong Thực Tế

Bẫy số 1: Cuộc chiến giữa Bitwise và Equality (Lỗi kinh điển trong C/C++ và Golang)

Khi làm việc với các hệ thống phân quyền (bitwise permission) hoặc xử lý cờ (flags) từ phần cứng, anh em rất hay viết thế này:

int flags = 5; // 0101 in binary
int MASK = 1;  // 0001 in binary

// Kiểm tra xem cờ MASK có đang bật trong flags hay không?
if (flags & MASK == 1) {
    // Làm gì đó...
}

Lý do: Toán tử so sánh bằng == có thứ tự ưu tiên CAO HƠN toán tử Bitwise AND &. Trình biên dịch sẽ hiểu đoạn code trên là:

if (flags & (MASK == 1)) \rightarrow if (5 & 1) \rightarrow if (1). Mặc dù case này vô tình đúng, nhưng nếu MASK = 2 (Kiểm tra bit thứ 2), biểu thức thành if (flags & (2 == 1))\rightarrow if (flags & 0) \rightarrow Luôn sai!Cách viết chuẩn: Luôn bọc Bitwise lại.

if ((flags & MASK) == MASK) { ... }

Bẫy số 2: Gán giá trị bên trong lệnh IF (Assignment vs Condition)

Để tiết kiệm vài dòng code, anh em Backend (đặc biệt là Node.js và PHP) hay gom lệnh gán và kiểm tra vào chung một chỗ:

let user;
// Lấy data từ DB, nếu có thì làm tiếp
if (user = fetchUserById(id) == null) {
    throw new Error("Not found");
}

Hậu quả: Biến user sẽ mang giá trị true hoặc false chứ không phải là Object User. Lý do: Toán tử so sánh == ưu tiên cao hơn phép gán =. Hệ thống sẽ chạy fetchUserById(id) == null trước ra kết quả là boolean, sau đó gán boolean đó cho biến user.

Cách viết chuẩn:

if ((user = fetchUserById(id)) == null) { ... }

Bẫy số 3: Lỗi toán tử ba ngôi (Ternary Operator) lồng nhau

Đây là ác mộng thực sự, đặc biệt nếu bạn nhảy qua nhảy lại giữa các ngôn ngữ.

// Code PHP hoặc Javascript
$result = $score > 90 ? 'A' : 
          $score > 80 ? 'B' : 'C';

Trong Javascript/C++: Ternary có tính kết hợp Right-to-Left, nên nó chạy đúng như bạn nghĩ: A, nếu không thì check tiếp B, nếu không thì C.

Trong PHP (trước version 8.0): Ternary có tính kết hợp Left-to-Right. Nó sẽ gom ($score > 90 ? 'A' : $score > 80) lại trước! Kết quả trả về sẽ làm bạn khóc thét.

Bẫy số 4: Con trỏ và Toán tử tăng (Chuyên trị C/C++)

Với các vòng lặp xử lý mảng, cú pháp *p++ cực kỳ phổ biến.

int arr[] = {10, 20, 30};
int *p = arr;
int val = *p++;

Nhiều người lầm tưởng *p++ là tăng giá trị tại địa chỉ p lên 1 (tức là 10 thành 11). Nhưng không, toán tử hậu tố ++ (Postfix) ưu tiên cao hơn toán tử giải tham chiếu * (Dereference). Nó sẽ lấy giá trị tại p gán cho val (val = 10), sau đó tăng địa chỉ của con trỏ p lên phần tử tiếp theo (p trỏ tới 20). Nếu muốn tăng giá trị, bạn phải viết: (*p)++.

3. Kinh Nghiệm Thực Chiến Đúc Kết

Từ những vết thương trên, đây là bộ "Survival Guide" khi viết các biểu thức phức tạp:

Quy tắc tối thượng: Tôn thờ dấu Ngoặc Đơn () Dấu ngoặc đơn không tốn bộ nhớ, không làm code chạy chậm đi 1 nano-giây nào cả, nhưng nó cứu bạn khỏi hàng giờ ngồi debug. Đừng bao giờ cố gắng nhớ bảng thứ tự ưu tiên dài ngoằng gồm 15 cấp độ của C++ hay 20 cấp độ của Javascript. Dù bạn có nhớ, người maintain code của bạn (có thể là một junior) cũng sẽ không nhớ.

Thay vì viết thể hiện trình độ: if (a || b && c != d)

Hãy viết thể hiện sự chuyên nghiệp: if (a || (b && (c != d)))

Tách biến trung gian (Explainatory Variables) Nếu một lệnh if chứa từ 3 toán tử logic trở lên, hãy dừng lại. Đừng dồn tất cả vào một biểu thức. Hãy chia nhỏ nó ra các biến boolean có tên gọi rõ ràng (Self-documenting code).

// Đọc vào mờ cả mắt
if (status == 1 && (role == "admin" || (role == "editor" && authorId == currentId))) { ... }

// Refactor: Sạch sẽ, rõ ràng, không sợ sai precedence
isAdmin = (role == "admin")
isOwnerEditor = (role == "editor" && authorId == currentId)
hasPermission = isAdmin || isOwnerEditor

if (status == 1 && hasPermission) { ... }

Sử dụng Linter (ESLint, PHP_CodeSniffer, SonarQube) Đừng dùng mắt người để dò lỗi máy. Hãy cấu hình các công cụ Linter trong CI/CD pipeline hoặc ngay trên IDE (VSCode/IntelliJ). Chúng có các rules như no-mixed-operators sẽ gạch đỏ chót và bắt bạn phải bọc ngoặc () vào nếu phát hiện bạn đang trộn && với || hoặc trộn toán tử số học với bitwise.

Kết luận

Bỏ qua Operator Precedence là lỗi của Junior, nhưng tối nghĩa hóa code bằng cách bắt người khác phải nhớ quy tắc Precedence lại là lỗi của Senior. Viết code xịn không phải là viết code ngắn nhất, mà là viết code không thể bị hiểu sai (unambiguous).

Nhớ nhé anh em: "When in doubt, use parentheses" (Khi có chút nghi ngờ, hãy dùng dấu ngoặc đơn).

*** Anh em đã từng tốn bao nhiêu tiếng cuộc đời cho một con bug liên quan đến toán tử này? Chia sẻ cay đắng dưới comment nhé! Đừng quên Upvote nếu thấy bài viết gãi đúng chỗ ngứa!


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í