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.
(a & (b == c))
&mức 11a==mức 10bc
(b == c)= 1(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ức | toán tử | ghi chú |
|---|---|---|
| 3 | ! ~ - + một ngôi | kế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 && và || được thêm vào sau, người ta không dám đổi mức của & và | 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 là
(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à 1 dù 12 < 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.
1#include <iostream>2using namespace std;34int main() {5 int a = 12, b = 10, c = 10;67 cout << (a & b == c) << endl;8 cout << ((a & b) == c) << endl;9 cout << (a < b < c) << endl;10 return 0;11}
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ên | quy tắc từng bit |
|---|---|---|
& | và | cả hai bit bật thì bật |
| | hoặc | mộ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 << và >> dịch các bit sang trái hoặc phải. Dịch trái k bit là nhân với
2 mũ k, 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 && và || 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 / và * 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
- 1int a = 12, b = 10, c = 10; thì biểu thức a & b == c được C++ nhóm thành gì?
- 2cout << (1 << 2 + 3); in ra số nào?
- 3Vì sao kiểm tra số lẻ bằng n % 2 == 1 lại sai?
- 4Với if (b != 0 && a / b > 1), điều gì được chuẩn C++ BẢO ĐẢM khi b bằng 0?
- 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 == c là a & (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 là (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.
&& và || 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.