3 điểm bởi GN⁺ 2024-07-01 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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ường
  • case không nhất thiết chỉ xuất hiện ở cấp cao nhất của khối switch, nên cả một switch không có ngoặc nhọn kết hợp if (0) case... và chuỗi else if cũ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 (...) hay for (...)
    • 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ên case 2: xuất hiện sau đó sẽ không còn nằm trong switch nữa và gây lỗi
  • switch về bản chất là một cấu trúc gần với goto: nhảy tới nhãn case khớp
    • Dù trong khối switchint a = 123;puts(...), nếu nhảy tới default: thì phần mã khởi tạo phía trước sẽ không được thực thi
    • Khi đó a sẽ 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
  • Nhãn case không nhất thiết phải nằm ở cấp cao nhất của khối switch được liên kết
    • Dạng như if (0) case 0: puts("i = 0"); vẫn hoạt động
    • switch nhảy trực tiếp tới case đó nên phần if (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ện if (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
  • Chuỗi if ... else về 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ới switch để 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 ... 10 theo dải và default
    • Có thể xem ví dụ tại Godbolt
  • Toán tử mở rộng && của GNU cho phép lấy địa chỉ của nhãn và goto tớ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ũ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 = 0 tới i = 5 bằ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
  • 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

 
GN⁺ 2024-07-01
Ý kiến trên Hacker News
  • Ví dụ trên nói rằng nó in ra giá trị của a như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 ra
    Dù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ả return phí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”

    • Trong C, hành vi không xác định không thực sự quay 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
    • Trong C, chỉ khi truy cập đối tượng có thời gian lưu trữ tự động chưa khởi tạo thì mới là 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ại
  • Cũ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 switch không cần viết break ở 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

    • Có vẻ #define brkcase break;case cũng hoạt động tương tự, nhưng như vậy mục đích của macro hơi bị mất đi
    • Cách này hoạt động rất khó hiểu, nên có lẽ viết cùng luồng điều khiển đó bằng goto còn tốt hơn kiểu lạm dụng switch theo phong cách Duff's device
    • Cách này chỉ hoạt động khi phần thân của nhãn case là một dòng hoặc được bọc trong ngoặc nhọn
      Trướ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 case tiếp theo, phần còn lại vẫn fall-through”
      Nếu xem nhãn case chỉ 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

    • Nếu không phải là thứ mới được chuẩn hóa gần đây thì nó không phải C hợp lệ mà là phần mở rộng GNU
    • Có vẻ là phần mở rộng GNU C: https://gcc.gnu.org/onlinedocs/gcc/Case-Ranges.html
      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ó. clang thì tôi không chắc
  • Trướ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 đệ quy

    • Góp ý nhỏ: sys.stdout.write(f"{n}\n") có thể đổi thành print(n)
      Mã hiện tại, nếu bỏ f-string, trông giống phong cách Python 2 khi print còn là câu lệnh. Trong Python 3, print là hàm bình thường và trả về None, được đánh giá là false, nên and đầu tiên cũng phải đổi thành or
  • Một điều đáng ngạc nhiên khác là 4[arr] giống với arr[4]

    • Vì mảng được chuyển đổi thành con trỏ nên về cơ bản là *(array_label+offset), còn ở đây thành *(offset+array_label)
      Tức là *(arr+4)*(4+arr) giống nhau
    • Theo cùng nguyên lý, arr[i][j]j[i[arr]] cũng hoàn toàn giống nhau
      Chỉ cần biết a[x][y] giống (a[x])[y], và a[x] giống x[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/1807129116980007037

  • Tôi nghĩ trò switch kiểu này chẳng phải là phần quan trọng trong Duff's device sao

    • Duff's device dựa vào việc trong cú pháp K&R C có thể trộn khối switch với vòng lặp, lựa chọn để case fall-through nếu không break rõ ràng, và việc trong C vòng lặp có thể nhảy trở lại vào bên trong switch
      Duff 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 != 0 sẽ được công cụ xử lý đúng, thay vì con người