2 điểm bởi GN⁺ 2025-01-13 | 1 bình luận | Chia sẻ qua WhatsApp
  • Dù C chuẩn đã tiếp tục thay đổi đến C23, Walter Bright cho rằng những bất tiện cũ như đánh giá biểu thức hằng, tham chiếu tiến và phụ thuộc vào header vẫn cản trở luồng phát triển
  • ImportC, trình biên dịch C được tích hợp trong trình biên dịch D, tận dụng lợi thế là một triển khai mới để có thể chạy các hàm thỏa điều kiện tại thời điểm biên dịch ở những vị trí constant-expression
  • Khi CTFE khả dụng, có thể chạy các bài kiểm thử đơn vị như _Static_assert(sum(3, 4) == 7,...) mỗi lần biên dịch mà không cần tệp thực thi riêng
  • Ràng buộc về thứ tự khai báo trong C chuẩn dẫn đến việc lặp lại khai báo tiến và bố trí mã theo thứ tự ngược, nhưng ImportC chọn cách ít bị ràng buộc hơn bởi thứ tự khai báo toàn cục
  • Nếu nhập trực tiếp các khai báo từ tệp .c như __import dex;, có thể giảm nhu cầu dùng tệp .h riêng và cũng hạ bớt gánh nặng gỡ lỗi do header và phần triển khai không khớp nhau

Những bất tiện còn tồn tại trong C chuẩn

  • Dù C chuẩn đã được cải tiến đều đặn đến C23, Walter Bright cho rằng vẫn còn những điểm chưa được sửa
  • Cộng đồng Dlang đã nhúng một trình biên dịch C vào trong trình biên dịch của ngôn ngữ lập trình D và gọi trình biên dịch này là ImportC
  • ImportC được xây dựng mới từ đầu, nên có dư địa để xử lý những thiếu sót của C bằng công nghệ trình biên dịch hiện đại
  • Bài viết đề cập bốn hạng mục
    • đánh giá constant-expression
    • kiểm thử đơn vị tại thời điểm biên dịch
    • tham chiếu tiến của khai báo
    • nhập khai báo

Thực thi hàm tại thời điểm biên dịch và đánh giá biểu thức hằng

  • C có thể tính toán các biểu thức đơn giản tại thời điểm biên dịch bằng constant folding, nhưng trong C chuẩn không thể thực thi hàm tại thời điểm biên dịch
  • Nếu biên dịch đoạn mã ví dụ enum E { A = 3, B = 4, C = sum(5, 6) }; bằng gcc, sẽ xuất hiện lỗi cho biết giá trị enumerator của C không phải là hằng số nguyên
  • ImportC có thể biên dịch cùng đoạn mã đó
  • Định hướng là ở mọi nơi mà constant-expression xuất hiện trong cú pháp C, trình biên dịch nên có thể thực thi hàm tại thời điểm biên dịch
  • Tuy nhiên, hàm đó không được thực hiện các tác vụ như I/O, truy cập biến toàn cục có thể thay đổi hoặc system call

Kiểm thử đơn vị chạy mỗi lần biên dịch

  • Khi CTFE trở nên khả dụng, cách làm kiểm thử đơn vị cũng có thể thay đổi
  • Một lý do khiến kiểm thử đơn vị hiếm gặp trong mã C là sự phiền toái khi phải tạo target build và tệp thực thi riêng rồi chạy chúng
  • Nếu biên dịch đoạn mã ví dụ _Static_assert(sum(3, 4) == 7, "test #1"); bằng gcc, sẽ phát sinh lỗi vì biểu thức trong static assertion không phải là hằng số
  • ImportC có thể biên dịch đoạn mã này
  • Cách làm này giúp việc kiểm thử đơn vị cho các hàm có thể chạy tại thời điểm biên dịch trở nên đơn giản
    • không cần build riêng
    • không cần thêm thao tác
    • kiểm thử được chạy mỗi lần biên dịch mã
  • Bộ test của ImportC cũng sử dụng rộng rãi cách này

Tham chiếu tiến gây ra việc lặp khai báo

  • Trong C chuẩn, trình biên dịch chỉ biết những khai báo xuất hiện trước đó theo thứ tự từ vựng, nên tham chiếu tiến không được cho phép
  • Trong đoạn mã ví dụ, nếu floo() gọi dex() được định nghĩa phía sau trước, gcc sẽ báo lỗi vì khai báo ngầm của dex xung đột kiểu với định nghĩa thực tế
  • Nếu đảo thứ tự của floodex, cùng đoạn mã đó sẽ biên dịch bình thường
  • ImportC có thể chấp nhận các khai báo toàn cục bất kể chúng xuất hiện theo thứ tự nào
  • Khi không có tham chiếu tiến, phải thêm một khai báo riêng cho mỗi định nghĩa xuất hiện phía sau
    • trước hết viết khai báo như char dex(char *s, int i);
    • sau đó lại viết lại định nghĩa hàm ở phía sau
  • Cách làm này là công việc lặp lại không cần thiết, và khiến lập trình viên đặt các hàm leaf ở phía trên còn các hàm giao diện toàn cục ở phía dưới theo bố trí ngược
  • Bài viết phê phán rằng kiểu bố trí này chẳng khác nào đọc bài báo từ dưới lên trên nên không hợp lý

Nhập khai báo mà không cần tệp header

  • Trong C, để dùng một mô-đun bên ngoài, thông thường phải tạo các khai báo trong tệp .h rồi include chúng trong tệp .c
  • Cấu trúc ví dụ như sau
    • floo.c include dex.h và gọi dex()
    • dex.h khai báo char dex(char *s, int i);
    • dex.c include dex.h và định nghĩa dex()
  • Việc tạo tệp .h cho mỗi mô-đun bên ngoài làm tăng công việc lặp lại
  • Nếu tệp .h không khớp chính xác với tệp .c, có thể mất rất nhiều thời gian để tìm ra vấn đề nằm ở đâu
  • Cách được đề xuất là nhập trực tiếp dex.c
    • trong floo.c dùng __import dex;
    • trong dex.c chỉ giữ các định nghĩa như char dexx(char *s, int i) { return s[i]; }
  • Với cách này, hoàn toàn không cần viết tệp .h
  • ImportC cũng hỗ trợ cách làm này

Tài liệu liên quan

1 bình luận

 
GN⁺ 2025-01-13
Các ý kiến trên Hacker News
  • Một trong những thứ tôi nhớ nhất khi dùng ngôn ngữ không phải C là tệp header
    Tôi đặc biệt thích ở mã C việc phần công khai/riêng tư, giao diện/triển khai được tách bạch rất rõ. Chỉ cần lướt qua các tệp .h của thư viện là có thể biết cách dùng; thường thì .h chứa tài liệu sử dụng và không bị trùng lặp với .c. Cũng có thể đặt tài liệu vào .c, nhưng từ góc độ người dùng, việc đọc giao diện sẽ kém dễ chịu hơn nhiều

    • Lập luận này lúc nào tôi cũng thấy kỳ lạ, vì các ngôn ngữ khác giải quyết vấn đề này bằng công cụ, và tôi cho rằng như vậy tốt hơn
      Ví dụ trong Rust, nếu muốn xem giao diện và cách dùng của thư viện thì chỉ cần cargo doc --open. Toàn bộ API công khai được tự động tạo ra ở dạng có thể tìm kiếm, và không cần lặp mã hay quản lý thủ công giữa header và source
    • Tôi xem tệp header gần như là một mẹo vá yếu ớt để xử lý các nền tảng bị hạn chế tài nguyên của thập niên 70
      Nó chỉ hoạt động đúng khi tuân thủ quy ước, và trông khá thô sơ nếu so với những ngôn ngữ như Ada, nơi đặc tả giao diện và triển khai được thiết kế tốt, không cần phân tích cú pháp lặp lại. Tôi thích dùng C, nhưng phần này lẽ ra phải được thiết kế tốt hơn
    • Vừa đồng ý lại vừa không đồng ý
      Tệp header của C chỉ là cách chèn nguyên văn văn bản vào, nên chỉ ở mức “trông có vẻ chạy được đại khái”. Trong khi đó Ada có khái niệm package và phần thân package; package tương ứng với tệp header, còn phần thân package tương ứng với phần triển khai. Khi tôi dùng Ada từ rất lâu trước đây, kể cả khi phần triển khai thân package chưa sẵn sàng, mọi người vẫn có thể biên dịch dựa trên package, nên có thể thống nhất giao diện ngay cả trước khi triển khai. Theo một hướng khác, import của Python ánh xạ tự nhiên lên hệ thống tệp và không phải xử lý ngữ nghĩa include của C, nên tôi cũng thích nó trong vai trò “tệp header”
    • Có vẻ ở đây tồn tại một khác biệt về lối tư duy
      Khi có tệp header, bạn sẽ nghĩ về giao diện như một thứ khác với phần triển khai. Vì vậy, người quen với cách này sẽ thấy khó chịu với ý tưởng giao diện được tạo ra bởi công cụ. Giao diện không phải là sản phẩm phụ của triển khai, mà là thứ được thiết kế riêng một cách có chủ đích, và với một số người nó còn quan trọng hơn cả triển khai. Ngược lại, người quen với tài liệu tự động sinh sẽ thấy khó chịu khi giao diện không được tạo ra từ nguồn chân lý duy nhất là mã nguồn triển khai. Sau một thời gian dài dùng các ngôn ngữ có và không có tệp giao diện riêng, người ta sẽ cố định vào phe của mình và quên mất cảm giác khi suy nghĩ theo cách ngược lại
    • Phần lớn các ngôn ngữ module biên dịch đều đã làm được điều này
      Hoặc tách riêng như Modula-2, Modula-3, Ada, Standard ML, Caml Light, OCaml, F#, D; hoặc có thể tạo bằng công cụ văn bản hay đồ họa như Object Pascal, D, Haskell, Java, C#, F#, Swift, Go, Rust. Tất cả đều có kiểu mạnh hơn, biên dịch nhanh hơn và namespace đúng nghĩa. Toolchain của Rust và Swift vẫn còn chỗ cần cải thiện, nhưng công cụ của C luôn nguyên thủy so với những gì đang diễn ra bên ngoài Bell Labs. Nếu AT&T có thể khai thác thương mại, lịch sử có lẽ đã khác; kết cục là thay vì nhận được những quả cam chín mọng, chúng ta lại nhận về mấy quả chanh miễn phí. Dù vậy, họ đã tạo ra một thứ giống TypeScript cho C, và ngày nay nó cũng hỗ trợ kiểu collection có kiểm tra biên và module đúng nghĩa
  • Trong lĩnh vực này, tác giả có kiến thức và kinh nghiệm hơn hẳn, nên tôi tò mò ông ấy sẽ giải quyết các vấn đề sau thế nào
    Đánh giá biểu thức hằng sẽ đơn giản hơn nếu chỉ thực hiện trong một đơn vị dịch, nhưng điều đó giới hạn rất nhiều những gì có thể làm mà không lặp mã. Unit test tại thời điểm biên dịch có thể làm được ở mức nào đó nếu có thể biểu diễn test bằng macro, và sẽ dễ hơn nếu có thêm mục trước. Tham chiếu tiến tới khai báo có thể gặp phản đối mạnh, vì trình biên dịch sẽ chuyển từ 1 pass sang 2 pass và gây ảnh hưởng hiệu năng. Với những người biên dịch codebase khổng lồ đồng thời song song hóa theo đơn vị dịch, điều này có thể khó chấp nhận. Nhập khai báo là một thay đổi phá vỡ tương thích. Trong C, tôi từng định nghĩa biến rồi import một tệp .c, thay đổi biến, rồi import lại cùng tệp .c để triển khai thứ gì đó hơi giống template. Tôi cũng từng đặt nhiều định nghĩa trước SQLite C Amalgamation và thêm hàm để lộ các hàm nội bộ; có vẻ mọi trường hợp sử dụng kiểu này đều sẽ bị phá vỡ. Tôi tò mò liệu có giải pháp nào cho những vấn đề như vậy không

    • Đúng là trình biên dịch phải nhìn thấy mã nguồn của hàm được đánh giá
      Trong C có thể làm bằng #include, còn trong D thì xử lý bằng cách import module chứa mã cần thiết. Cách biểu diễn test bằng macro không phù hợp nếu muốn test hàm. Ví dụ chỉ được đưa ra đơn giản để dễ hiểu, còn cách dùng thực tế có thể phức tạp hơn nhiều. Về hiệu năng, D biên dịch nhanh hơn trình biên dịch C; lý do chính là bộ tiền xử lý C có cấu trúc hóa thạch đòi hỏi nhiều pass, còn D dùng import thay cho #include nên không biên dịch lặp đi lặp lại các tệp .h. Chiến lược của D là tách phân tích cú pháp khỏi phân tích ngữ nghĩa; dù có thể chậm hơn một chút, nó không phải chịu chi phí biên dịch lại các khai báo trùng lặp rồi gộp chúng thành một. Thực thi hàm tại thời điểm biên dịch có thể trở thành nút thắt nếu dùng nhiều, nhưng nếu dùng nhẹ thì hiệu năng vẫn ổn. Nếu bạn đang triển khai template trong C bằng các mẹo hack, thì bạn đã vượt ra ngoài ngôn ngữ rồi và cần một ngôn ngữ mạnh hơn. D có metaprogramming hàng đầu, và các ngôn ngữ template khác thường đi theo con đường của D
    • Cá nhân tôi không thích tham chiếu tiến tới vì nó khiến mã khó đọc hơn
      Khi đó không còn có thể tin rằng đồ thị phụ thuộc được đặt theo thứ tự sắp xếp tô-pô nữa
    • Các ngôn ngữ khác có vẻ vẫn ổn mà không cần tệp header hay khai báo tiến tới, nên tôi khó hiểu vì sao lại có phản đối
      Tôi cũng tò mò liệu các trình biên dịch C hiện đại trên thực tế có còn là single-pass hay không
  • Ví dụ về đánh giá biểu thức hằng trong bài viết khá đơn giản, nhưng với các trường hợp phức tạp hơn thì tốc độ trình biên dịch và lượng bộ nhớ sử dụng có thể xấu đi đáng kể, và có lẽ sẽ cần một máy ảo để tận dụng nó
    Vì vậy có thể hiểu khả năng nó bị xem là “quá phức tạp” để đưa vào chuẩn. Giá mà C++ hay C đi theo hướng import khai báo thay vì thứ pha trộn kỳ lạ được định nghĩa trong C++20. Chẳng hạn như #import "string.c" as str, tức là import một module dưới một symbol nào đó, rồi có thể truy cập mọi symbol không tĩnh của file như str.trim(" Hello World ");. Riêng việc đường dẫn file không tường minh như __import dex; thì tôi không thích. Trong trường hợp này không biết là import dex.d hay dex.c

    • Các ngôn ngữ phổ biến khác cũng làm được, và trong D, thực thi hàm tại thời điểm biên dịch là một tính năng rất phổ biến và hữu ích
      Dùng nhiều thì dĩ nhiên sẽ tốn thời gian biên dịch và bộ nhớ. Về máy ảo, bản thân constant folding vốn đã là một máy ảo rồi, và đây chỉ là mở rộng thêm xử lý lời gọi hàm vào đó. Ngữ nghĩa của C đơn giản nên cũng không tệ đến vậy. Cách import được đề xuất gần như giống với việc D import làm: https://dlang.org/spec/module.html#import-declaration. Vấn đề dex.d hay dex.c thực sự có xảy ra, và câu trả lời là cấu hình đường dẫn import. Tương tự đường dẫn include của trình biên dịch C
    • Hầu hết các trình biên dịch C thực tế có lẽ đã thực hiện đánh giá tại thời điểm biên dịch ở một mức nào đó để tối ưu hóa
      C cũng đã có biểu thức hằng. Rào cản lớn hơn là trình biên dịch phải truy cập được mã nguồn của hàm, nên có lẽ sẽ bị giới hạn ở các hàm trong cùng một đơn vị dịch. Và vấn đề con người — một ủy ban gồm đại diện của nhiều trình biên dịch phải thống nhất về ngữ nghĩa của kiểu đánh giá hằng như vậy — có thể còn lớn hơn nhiều
    • Việc này thực ra trông gần như 1:1 với tính năng constexpr của C++
      Gần như mọi trình biên dịch C cũng đồng thời là trình biên dịch C++, nên tôi tự hỏi việc hỗ trợ hàm và đánh giá constexpr trong C liệu có thể tệ đến mức đó không
  • Tôi luôn viết unit test cho mã C
    Nếu dùng một hệ thống build tốt và chấp nhận một chút boilerplate thì không khó. Test của thư viện npy gọi npy_load("tests/npy/uint8.npy") trong test_load_uint8(), assert số chiều, kích thước, kiểu, rồi gọi npy_free; trong main thì chạy kiểu PRINT_RUN(test_load_uint8);. Có thể dùng preprocessor để sinh một phần test, nhưng tôi thích giữ mọi thứ đơn giản

    • Hàm đó trông như có làm I/O nên sẽ không hoạt động như test tại thời điểm biên dịch
      Ví dụ unit test của trình biên dịch ImportC là những dạng như _Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok");_Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok");. Vì kiểm tra ngữ nghĩa tại thời điểm biên dịch nên không cần link và chạy. Càng nhiều test thì cách này càng nhanh đáng kể, và bộ test chạy càng nhanh thì năng suất càng tăng
    • Tôi cũng làm gần như cùng một cách
      Có một thời tôi đã đào khá sâu vào các framework unit test màu mè, nhưng nhận ra chẳng được lợi gì mấy và cuối cùng ổn định với một dạng gần như tương tự. Điểm mà người khác có thể nhìn không thiện cảm là tôi sẵn sàng #include file .c cần test để gọi các hàm static. Chỉ include đúng một file .c. Ngoài ra trước #include tôi có xử lý preprocessor nhỏ để đảm bảo NDEBUG không được định nghĩa, phòng trường hợp ai đó build ở “release mode” làm assert bị tắt
    • Tôi nghĩ việc làm cho test dễ chạy nhất có thể thực sự giúp ích rất nhiều để tiến tới một codebase có test
      Tôi đang dùng thứ tương tự: https://github.com/ensisoft/detonator/blob/master/base/test_.... Nó mượn khá nhiều từ boost.test.minimal và ban đầu là một header đơn lẻ, nhưng qua thời gian phải thêm một đơn vị dịch riêng. Kết luận của tôi là nếu giữ codebase ở trạng thái test luôn pass, thì sẽ ít cần hơn rất nhiều những độ phức tạp như báo cáo lỗi hay dung thứ lỗi của công cụ test
  • Unit test tại thời điểm biên dịch là một ý tưởng cũng không hay, giống như biến “import/biến/kết quả không dùng” từ cảnh báo thành lỗi
    Đó là một “tính năng bảo mẫu” lấy mất quyền kiểm soát khỏi lập trình viên, và rốt cuộc buộc họ phải đi qua thủ tục quan liêu để hoàn thành việc. Những test làm build thất bại kiểu này phù hợp với build “có vẻ xong rồi”, nhưng không phù hợp với build “vẫn đang làm”, vốn chiếm 99% số lần build. Nó giống như nói “chưa cất máy khoan thì không được dùng cưa bàn”

    • Phần này tôi khó đồng ý
      Nếu bạn đang cố diễn đạt một ý tưởng nào đó mà test tại thời điểm biên dịch báo là sai, thì thực ra có thể ý tưởng đó vẫn chưa hoàn chỉnh, hoặc bạn chưa suy nghĩ đủ về mọi hệ quả của cách diễn đạt đó. Nó giống kiểm tra kiểu của Haskell. Chương trình không vượt qua kiểm tra kiểu thì không thể biên dịch, buộc lập trình viên luôn chỉ diễn đạt những ý tưởng hoàn chỉnh. Về lý thuyết, điều đó có thể dẫn tới chương trình được cân nhắc kỹ hơn. Tuy nhiên nó cũng làm việc viết khó hơn, vì buộc lập trình viên phải tìm ra cả những góc mà họ “biết là không hợp lệ nhưng không quan tâm”
    • Những test tại thời điểm biên dịch kiểu này có thể gần với static_assert hơn, và rất có giá trị trong việc bắt các cách dùng không tương thích của hàm thư viện
      Tôi thấy đây là một ý tưởng khá hay
  • “hàm leaf đứng trước, còn hàm giao diện toàn cục đứng cuối” thì với tôi là ngược
    Vì nhiều lý do, tôi thích viết code theo thứ tự sắp xếp topo. Nó giống với cách viết code bên trong một hàm, giúp rõ ràng nên đặt hàm ở đâu trong module, và quan trọng nhất là làm cho các phụ thuộc vòng giữa các mảnh code nội bộ của module trở nên rất dễ thấy. Tôi không thích phụ thuộc vòng lắm, vì chúng khiến codebase rối hơn và làm module khó hiểu như một đơn vị độc lập. Trong Python, chúng còn có thể tạo ra những vấn đề không lộ ra cho đến khi chạy[0], và vì import vòng quá phổ biến nên có vẻ các trình kiểm tra kiểu hiện nay mặc định tắt chẩn đoán đó[1]. Những ngôn ngữ không hỗ trợ tham chiếu tiến như C, OCaml, SML cho phép áp dụng “nguyên tắc ít bất ngờ nhất” cho phụ thuộc vòng. OCaml thậm chí còn cấm phụ thuộc đệ quy giữa các hàm nếu không khai báo kiểu let rec fn1 = .. and fn2 = ..; khi viết thì hơi phiền, nhưng khi đọc lại là thông tin quan trọng
    [0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
    [1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (xem reportImportCycles)

  • Có cách giải thích rằng vì compiler chỉ biết những gì đứng trước về mặt từ vựng, nên thứ tự sẽ là “từ dưới lên”, với các hàm leaf đứng trước và các hàm giao diện toàn cục đứng cuối; nhưng kiểu thứ tự này cũng phổ biến trong các ngôn ngữ cho phép tham chiếu tiến như Python[0]
    Tôi tự hỏi đó là tàn dư của các ngôn ngữ không cho phép tham chiếu tiến, hay thật sự là cách hợp lý hơn với một số loại code nhất định
    [0] https://stackoverflow.com/a/73131538

    • Python không có tham chiếu tiến
      Chỉ là các định danh trong thân hàm không được phân giải cho đến khi chính hàm đó được thực thi, và vào thời điểm đó thì mọi thứ trong phạm vi module đã được định nghĩa. Bạn có thể tự kiểm chứng bằng cách đặt bất kỳ tên nào vào thân hàm rồi load module
    • Trong code của tôi, giao diện công khai luôn có xu hướng nằm phía trên, còn chi tiết triển khai đi về cuối file
      Cũng không có quy tắc nghiêm ngặt nào, nhưng đọc như vậy có vẻ gọn gàng và hợp lý hơn. Đặc biệt khi phần triển khai lớn, tôi không muốn phải cuộn qua hết tất cả mới thấy về cơ bản nó làm gì. Không biết người khác làm thế nào
  • Những “điều hiển nhiên” tôi muốn C làm gồm hỗ trợ kiểu slice chứa con trỏ và độ dài, các phiên bản có thể reentrant và nếu được thì thread-safe của các API dùng trạng thái toàn cục, chuẩn hóa một thứ như defer của Go/Zig hoặc thuộc tính cleanup của GCC, và hỗ trợ portable cho Unicode và UTF-8

    • Phần lớn những thứ này chẳng phải là các tính năng mong muốn ở thư viện chuẩn, chứ không phải việc của bản thân ngôn ngữ sao?
  • Việc cho rằng ở mọi nơi trong cú pháp C có biểu thức hằng, compiler phải có thể chạy hàm tại thời điểm biên dịch miễn là hàm đó không làm I/O, không truy cập biến toàn cục có thể thay đổi, không gọi system call, v.v. là điều rất dễ bị phá vỡ
    Chỉ cần chọn một hàm thuần chạy cực lâu là được. Ví dụ nếu int busybeaver(int n) {...} là một hàm thuần trả về thời gian sống tối đa của máy busy beaver n trạng thái, thì code như int x = busybeaver(99); sẽ thành vấn đề

  • C23 có constexpr, nhưng hiện vẫn chưa thể gắn cho hàm
    Tuy vậy đã có đề xuất: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf