Chuyển tới nội dung chính

Toán tử, độ ưu tiên và phép toán bit

Mọi lỗi cho tới giờ trong học phần này đều tự khai ra: sai kiểu thì trình biên dịch mắng, sai chỉ số thì at ném ngoại lệ, sai khoá thì map phình lên và bạn thấy size đổi. Bài này nói về một loại lỗi khác hẳn, loại không khai gì cả.

a & b == c biên dịch trơn tru, chạy trơn tru, in ra một con số trông rất hợp lý. Nó chỉ có một vấn đề: nó không tính thứ bạn nghĩ. C++ gom nó thành a & (b == c) chứ không phải (a & b) == c, và không ai báo cho bạn biết.

Thử ngay: gõ một biểu thức, xem C++ đóng ngoặc thế nào

Ô dưới đây nhận biểu thức của bạn, dựng lại đúng cách trình biên dịch nhóm nó, rồi tính từng bước từ trong ra ngoài. Năm nút có sẵn dẫn thẳng tới năm tình huống đáng nhớ nhất.

Độ ưu tiên · gõ một biểu thức, xem C++ đóng ngoặc thế nào
giá trị = 02 bước tính
C++ nhóm biểu thức này thành
(a & (b == c))
Cây, đọc từ trên xuống
  • &mức 11
    • a
    • ==mức 10
      • b
      • c
Tính từ trong ra ngoài
  1. (b == c) = 1
  2. (a & (b == c)) = 0

Cách dùng hiệu quả nhất: đoán trước rồi mới bấm. Viết ra giấy cách bạn nghĩ nó sẽ đóng ngoặc, rồi so. Chỗ nào lệch chính là chỗ đáng nhớ, và bạn sẽ không quên nó nữa vì nó vừa làm bạn sai.

Bảng độ ưu tiên, phần thật sự hay dùng

Số càng nhỏ càng chặt, tức càng được gom trước. Bảng đầy đủ của C++ có 17 mức, dưới đây là 10 mức bạn gặp hằng ngày:

mứctoán tửghi chú
3! ~ - + một ngôikết hợp phải
5* / %
6+ - hai ngôi
7<< >>thua cả cộng lẫn trừ
9< <= > >=
10== !=chặt hơn mọi toán tử bit
11&
12^
13|
14&&
15||

Ba dòng in đậm ở trên là ba cái bẫy, và cả ba đều đến từ cùng một chuyện: bảng này được thừa kế từ ngôn ngữ C, nơi các toán tử bit ra đời trước các toán tử logic. Khi &&|| được thêm vào sau, người ta không dám đổi mức của &| vì sợ hỏng mã cũ.

Mọi toán tử hai ngôi trong bảng đều kết hợp trái, nên a - b - c(a - b) - c. Chỉ nhóm một ngôi ở mức 3 và phép gán mới kết hợp phải.

Bẫy thứ nhất: so sánh chặt hơn toán tử bit

int a = 12, b = 10, c = 10;
cout << (a & b == c); // in ra 0

Bạn nghĩ nó lấy a & b rồi so với c. C++ thì lấy b == c được 1, rồi tính 12 & 1 được 0. Cùng một dòng, hai cách hiểu, hai kết quả khác nhau, và không có cảnh báo nào.

Chỗ này hiểm gấp đôi vì kết quả sai lại rất hay trùng với kết quả đúng. Với a chẵn thì a & 1 bằng 0 và điều kiện sai; với a lẻ thì bằng 1 và điều kiện đúng. Chương trình chạy gần đúng đủ lâu để bạn tin nó.

Cách chữa chỉ có một, và nó không tốn gì: đóng ngoặc.

cout << ((a & b) == c); // rõ ý, và rõ luôn cho người đọc sau

Bẫy thứ hai: dịch bit thua phép cộng

cout << (1 << 2 + 3); // in ra 32, không phải 7

+ ở mức 6, << ở mức 7, nên 2 + 3 được gom trước và ta có 1 << 5 tức 32. Nếu bạn định viết (1 << 2) + 3 tức 7 thì phải viết đúng như vậy.

Cái bẫy này đặc biệt hay gặp khi in ra màn hình, vì << của cout chính là toán tử ấy:

cout << "tong = " << a + b << endl; // đúng, vì + chặt hơn <<
cout << "so sanh: " << a < b << endl; // LỖI BIÊN DỊCH, vì < thua <<

Dòng thứ hai bị gom thành (cout << "so sanh: " << a) < b, tức so sánh một luồng với một số. Ở đây bạn may mắn: trình biên dịch không cho qua. Dòng thứ nhất thì đúng thật, nhưng đúng vì may, không phải vì bạn biết mức của <<.

Bẫy thứ ba: a < b < c không có nghĩa như trong toán

int a = 12, b = 10, c = 10;
cout << (a < b < c); // in ra 1

Trong toán, a < b < c nghĩa là cả hai bất đẳng thức cùng đúng. Trong C++ thì < kết hợp trái, nên nó là (a < b) < c. Vế a < b cho 0, rồi 0 < 10 cho 1. Kết quả là 112 < 10 hiển nhiên sai.

Điều cần viết là a < b && b < c. Đây là một trong vài chỗ mà C++ không báo lỗi giúp bạn, vì (a < b) < c là một biểu thức hoàn toàn hợp lệ về kiểu.

Cùng ba số, ba cách đóng ngoặc, ba kết quả C++
1#include <iostream>
2using namespace std;
3
4int main() {
5 int a = 12, b = 10, c = 10;
6
7 cout << (a & b == c) << endl;
8 cout << ((a & b) == c) << endl;
9 cout << (a < b < c) << endl;
10 return 0;
11}
Ngăn xếp stack
main()
a= 12b= 10c= 10
Bộ nhớ động heap
(trống)
Ba biến nhận giá trị. Chú ý b và c bằng nhau, còn a là 12 tức nhị phân 1100.
1/6

Toán tử bit, và khi nào chúng đáng dùng

Bốn toán tử làm việc trên từng bit của số nguyên:

toán tửtênquy tắc từng bit
&cả hai bit bật thì bật
|hoặcmột trong hai bật thì bật
^xorđúng một bit bật thì bật
~lậtđảo mọi bit
int x = 12; // 0000 1100
int y = 10; // 0000 1010
x & y; // 0000 1000 = 8
x | y; // 0000 1110 = 14
x ^ y; // 0000 0110 = 6
~x; // = -13

~12 cho -13 chứ không phải một số dương, vì số nguyên có dấu dùng biểu diễn bù hai: lật mọi bit của n cho ra -n - 1. Bài Biến, kiểu và nhập xuất đã nói về dải giá trị của từng kiểu, và bù hai chính là cách dải ấy được xếp.

Chỗ dùng thật sự phổ biến là cờ bit: gộp nhiều lựa chọn đúng sai vào một số.

const int DOC = 1; // 0001
const int GHI = 2; // 0010
const int CHAY = 4; // 0100

int quyen = DOC | GHI; // bật hai cờ
bool duocGhi = quyen & GHI; // hỏi một cờ
quyen = quyen & ~GHI; // tắt một cờ
quyen = quyen ^ CHAY; // lật một cờ

Còn <<>> dịch các bit sang trái hoặc phải. Dịch trái k bit là nhân với 2k, dịch phải là chia. Đừng dùng chúng để thay phép nhân chia cho nhanh: trình biên dịch tự làm việc đó tốt hơn bạn, và x << 1 khó đọc hơn x * 2 với người đến sau.

:::caution Dịch quá số bit là hành vi không xác định Với int 32 bit, x << 32 hay x << -1 đều là hành vi không xác định, không phải cho ra 0. Sim ở đầu bài từ chối tính những ca ấy thay vì đoán hộ bạn. :::

Chia nguyên và dấu của phép dư

cout << 7 / 3; // 2
cout << -7 / 3; // -2, không phải -3
cout << 7 % 3; // 1
cout << -7 % 3; // -1, không phải 2

Từ C++11, phép chia nguyên cắt về phía 0 chứ không làm tròn xuống. Và phép dư mang dấu của số bị chia, không phải dấu của số chia. Hai luật ấy gắn với nhau qua một đẳng thức mà chuẩn bảo đảm:

(a / b) * b + a % b == a

Hệ quả thực dụng hay làm hỏng bài tập: kiểm tra chẵn lẻ bằng n % 2 == 1 sai với số âm, vì -3 % 2 cho -1 chứ không cho 1. Cách viết đúng cho mọi dấu là n % 2 != 0.

Rút gọn của &&|| là hợp đồng, không phải mẹo tối ưu

if (b != 0 && a / b > 1) { ... }

Nếu b bằng 0 thì vế trái sai, và C++ bảo đảm vế phải không được tính. Phép chia cho 0 không bao giờ xảy ra. Đây là rút gọn (short-circuit), và nó là một điều khoản của chuẩn chứ không phải một tối ưu mà trình biên dịch tuỳ hứng làm hay không.

Bấm nút b != 0 && a / b trên sim ở đầu bài để thấy vết tính chỉ có hai bước, và phép chia không hề xuất hiện.

Cùng lý do đó, thứ tự hai vế là ý nghĩa của mã, không phải chuyện thẩm mỹ:

if (p != nullptr && p->x > 0) // an toàn
if (p->x > 0 && p != nullptr) // sập, và câu kiểm tra đứng sau vô dụng

Đổi chỗ hai vế ở đây không phải là viết cách khác, mà là viết một chương trình khác. Bài Rẽ nhánh đã dùng tính chất này mà chưa gọi tên nó ra.

Thứ tự tính thì ngược lại: đừng trông cậy

Rút gọn được bảo đảm, nhưng thứ tự tính hai vế của một phép toán thường thì không. Với f() + g(), chuẩn không nói hàm nào chạy trước, và hai trình biên dịch có quyền chọn khác nhau.

int i = 0;
int x = i++ + i++; // hành vi không xác định
cout << i++ << i++; // cũng vậy, dù trông rất vô hại

i++ + i++ không phải khó đoán, nó là hành vi không xác định: chương trình được phép làm bất cứ điều gì. Sim ở đầu bài cố ý không nhận i++, vì mô phỏng một thứ chuẩn từ chối định nghĩa là dạy sai một cách rất thuyết phục.

Quy tắc thực dụng: trong một câu lệnh, đừng sửa một biến quá một lần, và đừng vừa sửa vừa đọc nó ở chỗ khác. Tách thành hai dòng thì mất một giây, còn gỡ một lỗi loại này thì mất một buổi.

Bài tập thực hành

Bài tập 1: đọc ra kết quả trước khi chạy

Với int a = 6, b = 3, c = 2;, hãy tự đóng ngoặc rồi tính, sau đó kiểm lại bằng sim ở đầu bài.

a - b - c // ((a - b) - c) = 1
a / b * c // ((a / b) * c) = 4, vì / và * cùng mức 5, kết hợp trái
a % b == c // ((a % b) == c) = 0, vì % chặt hơn ==
a | b & c // (a | (b & c)) = 6, vì & chặt hơn |
a > b == c > 0 // ((a > b) == (c > 0)) = 1

Dòng thứ hai là chỗ hay sai nhất: nhiều người đọc thành a / (b * c) được 1. Nhưng /* cùng mức, nên chỉ tính kết hợp mới quyết định, và kết hợp trái cho (6 / 3) * 2 tức 4.

Bài tập 2: đếm số bit đang bật

int demBit(unsigned int n) {
int dem = 0;
while (n) {
dem += n & 1; // bit thấp nhất có bật không
n >>= 1; // đẩy sang phải một bit
}
return dem;
}

Dùng unsigned int chứ không dùng int là chủ ý: với số âm, >> trên kiểu có dấu giữ lại bit dấu, nên vòng lặp không bao giờ về 0 và chương trình treo. Đây là một trong vài chỗ mà chọn sai kiểu không cho sai số, mà cho treo máy.

Bài tập 3: bật, tắt, hỏi một cờ

inline int bat(int co, int mask) { return co | mask; }
inline int tat(int co, int mask) { return co & ~mask; }
inline bool hoi(int co, int mask) { return (co & mask) != 0; }

Chú ý cặp ngoặc trong (co & mask) != 0. Bỏ nó đi thì co & mask != 0 gom thành co & (mask != 0), tức co & 1, và hàm trả lời về bit thấp nhất thay vì về cờ bạn hỏi. Đây đúng là bẫy thứ nhất của bài, gặp lại trong mã thật.

Bài tập 4: hoán đổi hai số bằng ^

void doiCho(int& x, int& y) {
if (&x == &y) return; // chốt bắt buộc
x ^= y;
y ^= x;
x ^= y;
}

Mẹo này thường được khoe là đổi chỗ không cần biến tạm. Chốt &x == &y mới là phần đáng học: nếu hai tham chiếu trỏ cùng một ô nhớ thì x ^= y biến nó thành 0 và cả hai giá trị mất sạch. Trong mã thật hãy dùng std::swap, nó rõ ràng hơn, không có ca hỏng, và trình biên dịch sinh mã tốt bằng hoặc hơn.

Câu hỏi tự kiểm

Kiểm tra nhanh: toán tử và độ ưu tiên0/5 đúngchưa trả lời
  1. 1int a = 12, b = 10, c = 10; thì biểu thức a & b == c được C++ nhóm thành gì?
  2. 2cout << (1 << 2 + 3); in ra số nào?
  3. 3Vì sao kiểm tra số lẻ bằng n % 2 == 1 lại sai?
  4. 4Với if (b != 0 && a / b > 1), điều gì được chuẩn C++ BẢO ĐẢM khi b bằng 0?
  5. 5int i = 0; int x = i++ + i++; đúng nhất là gì?

Tóm tắt

Độ ưu tiên là loại luật mà hiểu sai vẫn biên dịch được và vẫn ra số, nên nó không tự khai như các lỗi khác trong học phần này.

Ba bẫy đáng thuộc, cả ba đều do bảng thừa kế từ C: so sánh chặt hơn toán tử bit nên a & b == ca & (b == c); phép cộng chặt hơn phép dịch nên 1 << 2 + 3 cho 32; và < kết hợp trái nên a < b < c(a < b) < c chứ không mang nghĩa như trong toán.

Chia nguyên cắt về phía 0, và phép dư mang dấu của số bị chia, nên -7 % 3 cho -1. Vì thế n % 2 == 1 trượt mọi số lẻ âm, còn n % 2 != 0 thì đúng với mọi dấu.

&&|| rút gọn, và đó là điều khoản của chuẩn chứ không phải tối ưu, nên p != nullptr && p->x > 0 an toàn còn bản đổi chỗ hai vế thì sập.

Ngược lại, thứ tự tính hai vế của một phép toán thường thì không được bảo đảm, và i++ + i++ là hành vi không xác định chứ không phải kết quả khó đoán.

Khi phân vân, hãy đóng ngoặc. Cặp ngoặc không tốn một chu kỳ máy nào, và nó nói rõ ý cho cả trình biên dịch lẫn người đọc sau bạn.