Dự án cuối tuần: Thử thách thú vị với ngôn ngữ C
(lcamtuf.substack.com)- C đơn giản và giàu khả năng biểu đạt, nhưng chỉ riêng câu lệnh switch và các quy tắc về nhãn cũng có thể tạo ra đoạn mã làm cả lập trình viên dày dạn phải bối rối
- Kiểu làm rối mã theo phong cách IOCCC thường che giấu code bằng macro và định dạng, nhưng ngay cả chỉ với cú pháp của C cũng có thể tạo ra đoạn mã vẫn đọc được nhưng rất xa lạ
switch (...)không bắt buộc phải có dấu ngoặc nhọn, và vì nó nhảy tới nhãn case khớp nên có thể hoạt động khác với luồng khởi tạo khối thông thườngcasekhông nhất thiết chỉ xuất hiện ở cấp cao nhất của khốiswitch, nên cả một switch không có ngoặc nhọn kết hợpif (0) case...và chuỗielse ifcũng vẫn biên dịch được- Phần mở rộng địa chỉ nhãn
&&của GNU C còn cho phép tự tạo switch hay cả vòng lặp dựa trên nhãn, nhưng một số ví dụ chỉ dành cho GCC và có thể không an toàn trước hành vi không xác định
Cú pháp C tạo ra những đoạn mã xa lạ
- C có nhiều khiếm khuyết, nhưng nhờ cú pháp đơn giản và khả năng biểu đạt cao, nó vẫn được dùng để viết các phần mềm lớn như hệ điều hành
- Cú pháp ngắn gọn của nó cũng ảnh hưởng tới cấu trúc mã của nhiều ngôn ngữ hậu bối phổ biến, từ Java tới Go
- Ví dụ tiêu biểu của mã làm rối là IOCCC
- Các tác phẩm đoạt giải IOCCC thường được tạo nên từ macro tiền xử lý, định dạng bất thường, tên biến không hữu ích và các biểu thức số học khó hiểu
- Những đoạn mã như vậy rất đáng nể, nhưng vì phải đảo ngược chúng thành mã bình thường nên việc học từ đó có thể kém thú vị hơn
Những góc sâu của C lộ ra qua switch, case và goto
switch (...)có thể được viết không cần dấu ngoặc nhọn nhưif (...)hayfor (...)switch (i) case 1: puts("i = 1");biên dịch được- Nếu không có ngoặc nhọn thì chỉ một câu lệnh được gắn với
switch, nêncase 2:xuất hiện sau đó sẽ không còn nằm trongswitchnữa và gây lỗi
switchvề bản chất là một cấu trúc gần với goto: nhảy tới nhãncasekhớp- Dù trong khối
switchcóint a = 123;vàputs(...), nếu nhảy tớidefault:thì phần mã khởi tạo phía trước sẽ không được thực thi - Khi đó
asẽ không được khởi tạo thành 123, và về mặt kỹ thuật đây là hành vi không xác định - Có thể xem ví dụ tại Godbolt
- Dù trong khối
- Nhãn
casekhông nhất thiết phải nằm ở cấp cao nhất của khốiswitchđược liên kết- Dạng như
if (0) case 0: puts("i = 0");vẫn hoạt động - Vì
switchnhảy trực tiếp tớicaseđó nên phầnif (0)phía trước sẽ bị bỏ qua - Sau khi
puts(...)tương ứng chạy xong, các câu lệnh in khác phía sau vẫn tiếp tục bị điều kiệnif (0)chặn lại, nên có thể tránh fallthrough ngay cả khi không cóbreak - Có thể xem ví dụ tại Godbolt
- Dạng như
- Chuỗi
if ... elsevề mặt cú pháp hoạt động như một câu lệnh cấp cao nhất duy nhất, nên có thể kết hợp vớiswitchđể tạo ra một switch quái lạ không có ngoặc nhọn- Ví dụ còn bao gồm cả
case 1 ... 10theo dải vàdefault - Có thể xem ví dụ tại Godbolt
- Ví dụ còn bao gồm cả
- Toán tử mở rộng
&&của GNU cho phép lấy địa chỉ của nhãn vàgototới địa chỉ đó- Có thể dùng nó để tự triển khai switch theo kiểu
goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i]; - Có thể xem ví dụ tại Godbolt
- Có thể dùng nó để tự triển khai switch theo kiểu
- Cũng với phần mở rộng GNU này, có thể tạo vòng lặp dựa trên nhãn ngay trong khai báo biến mà không cần
for (...)- Ví dụ tạo một vòng lặp in ra từ
i = 0tớii = 5bằng nhãn vàgoto *&&_ - Có thể xem ví dụ tại Godbolt
- Đoạn mã cuối này chỉ dành cho GCC và có thể không an toàn trước hành vi không xác định
- Ví dụ tạo một vòng lặp in ra từ
- Trong C, ngay cả khi không cố tình làm rối mã, vẫn có thể tạo ra những dạng mã hoàn toàn xa lạ và gây bối rối
1 bình luận
Ý kiến trên Hacker News
Ví dụ trên nói rằng nó in ra giá trị của
anhưng không được khởi tạo thành 123, tuy nhiên trong C điều đó thực ra cũng có thể xảy raDùng một biến chưa khởi tạo không có nghĩa là đọc “giá trị tình cờ còn sót lại trong vùng nhớ đó”, mà là hành vi không xác định, nên trình biên dịch muốn làm gì cũng được
Ví dụ, nó có thể luôn khởi tạo vùng nhớ đó thành 123, hoặc xem toàn bộ đoạn mã là hành vi không xác định rồi xóa hết lệnh để không in ra gì cả. Xa hơn nữa, nó có thể tối ưu hóa bỏ cả
returnphía sau hoặc các lệnh phía trước, khiến hành vi không xác định trông như có thể gây ảnh hưởng “ngược thời gian”Nó có thể ảnh hưởng đến các lệnh trước đó, nhưng việc sắp xếp lại mã hay các biến đổi phức tạp vẫn xảy ra ngay cả khi không có hành vi không xác định
Đối tượng tĩnh luôn được khởi tạo, nên tình huống này không xảy ra
Phần còn lại là các đối tượng động, chẳng hạn như thành viên chưa khởi tạo của một struct được cấp phát bằng
malloc; đọc bộ nhớ động chưa khởi tạo không phải là hành vi không xác định trong C, mà bạn sẽ nhận được giá trị mà các bit chưa khởi tạo đó biểu thị. Nếu kiểu đó không có biểu diễn bẫy thì không thể thất bạiCũng có thể viết cú pháp kiểu này
switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }Có thể biến nó thành một dạng
switchkhông cần viếtbreakở cuối mỗi nhánh, và cũng có thể dùng macro như#define brkcase if (0) case. Trình biên dịch có lẽ sẽ không thích luồng điều khiển này, nhưng nhìn chung chắc sẽ loại bỏ được nó ổn#define brkcase break;casecũng hoạt động tương tự, nhưng như vậy mục đích của macro hơi bị mất đigotocòn tốt hơn kiểu lạm dụng switch theo phong cách Duff's devicecaselà một dòng hoặc được bọc trong ngoặc nhọnTrước đây tôi từng dùng cấu trúc này với nghĩa là “chỉ bỏ qua dòng đầu tiên của nhãn
casetiếp theo, phần còn lại vẫn fall-through”Nếu xem nhãn
casechỉ là nhãn đơn thuần chứ không phải dấu phân cách giữa các câu lệnh thì mọi thứ đều hợp lýCó thể dùng kỹ thuật này để triển khai coroutine trong C: https://stackoverflow.com/questions/24202890/switch-based-co...
Không hiểu sao tôi lại không biết
case 1 ... 10:là C hợp lệTôi đã dùng C nhiều năm rồi, và tò mò không biết nó xuất hiện từ chuẩn nào
Tôi không tìm được lịch sử của phần mở rộng này, và theo tôi biết thì chuẩn C không có.
clangthì tôi không chắcTrước đây, cho vui, tôi từng viết một đoạn mã C kỳ quặc đếm ngược từ 10 về 1
Cả phiên bản C, Python và shell đều có thể viết thành một dòng bằng
&&và lời gọi đệ quysys.stdout.write(f"{n}\n")có thể đổi thànhprint(n)Mã hiện tại, nếu bỏ
f-string, trông giống phong cách Python 2 khiprintcòn là câu lệnh. Trong Python 3,printlà hàm bình thường và trả vềNone, được đánh giá là false, nênandđầu tiên cũng phải đổi thànhorMột điều đáng ngạc nhiên khác là
4[arr]giống vớiarr[4]*(array_label+offset), còn ở đây thành*(offset+array_label)Tức là
*(arr+4)và*(4+arr)giống nhauarr[i][j]vàj[i[arr]]cũng hoàn toàn giống nhauChỉ cần biết
a[x][y]giống(a[x])[y], vàa[x]giốngx[a]arr[i][j]→(arr[i])[j]→(i[arr])[j]→j[i[arr]]Đoạn mã rối ở cuối bài còn cho thấy một phần mở rộng GCC khác: https://stackoverflow.com/questions/34559705/ternary-conditi...
Tôi đã thấy các kỹ thuật nghịch ngợm kiểu này của tác giả blog trên Twitter trước rồi
Cũng có thể tạo vòng lặp bằng câu lệnh
switch: https://twitter.com/lcamtuf/status/1807129116980007037Tôi nghĩ trò
switchkiểu này chẳng phải là phần quan trọng trong Duff's device saoswitchvới vòng lặp, lựa chọn đểcasefall-through nếu khôngbreakrõ ràng, và việc trong C vòng lặp có thể nhảy trở lại vào bên trongswitchDuff khi đó muốn tối ưu I/O ánh xạ bộ nhớ (MMIO), còn trong C ngày nay có lẽ người ta sẽ không làm theo cách này nữa. Giờ MMIO không còn nhanh ngang tốc độ lệnh CPU, và chỉ cần dữ liệu hơi nhiều là đã có thể dùng DMA
Các ngôn ngữ hiện đại có lẽ cũng sẽ không xử lý MMIO như một phép gián tiếp con trỏ đơn giản, còn trong C, để duy trì điều đó, người ta đã tiếp tục thêm các đường vòng vào hệ thống kiểu
Cá nhân tôi cho rằng thứ kế thừa “Device” của Tom Duff là cơ chế iterate loops của WUFFS. Nó cho phép chỉ định cách bung một phần N bước của vòng lặp, đồng thời hứa hẹn kết quả giống như chạy phần thân vòng lặp chính N lần nhưng có thể nhanh hơn. Việc vector hóa dễ nắm bắt ý đồ hơn, và các trường hợp biên phiền phức như
M % N != 0sẽ được công cụ xử lý đúng, thay vì con người