Những điều hiển nhiên mà ngôn ngữ C nên làm
(digitalmars.com)- 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
.cnhư__import dex;, có thể giảm nhu cầu dùng tệp.hriê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
- đánh giá
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ủaCkhô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-expressionxuấ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ọidex()được định nghĩa phía sau trước, gcc sẽ báo lỗi vì khai báo ngầm củadexxung đột kiểu với định nghĩa thực tế - Nếu đảo thứ tự của
floovàdex, 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
- trước hết viết khai báo như
- 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
.hrồi include chúng trong tệp.c - Cấu trúc ví dụ như sau
floo.cincludedex.hvà gọidex()dex.hkhai báochar dex(char *s, int i);dex.cincludedex.hvà định nghĩadex()
- Việc tạo tệp
.hcho mỗi mô-đun bên ngoài làm tăng công việc lặp lại - Nếu tệp
.hkhô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.cdùng__import dex; - trong
dex.cchỉ giữ các định nghĩa nhưchar dexx(char *s, int i) { return s[i]; }
- trong
- 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
- ImportC: tài liệu ImportC
- D Language: tài liệu ngôn ngữ D
1 bình luận
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
.hcủa thư viện là có thể biết cách dùng; thường thì.hchứ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ềuVí 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à sourceNó 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
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,
importcủ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”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
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ôngTrong 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#includenê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 DKhi đó 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
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à importdex.dhaydex.cDù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.dhaydex.cthự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 CC 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
constexprcủ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á
constexprtrong C liệu có thể tệ đến mức đó khôngTô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
npygọinpy_load("tests/npy/uint8.npy")trongtest_load_uint8(),assertsố chiều, kích thước, kiểu, rồi gọinpy_free; trongmainthì chạy kiểuPRINT_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ảnVí 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");và_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ăngCó 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
#includefile.ccần test để gọi các hàm static. Chỉ include đúng một file.c. Ngoài ra trước#includetôi có xử lý preprocessor nhỏ để đảm bảoNDEBUGkhông được định nghĩa, phòng trường hợp ai đó build ở “release mode” làmassertbị tắtTô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.minimalvà 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ụ testUnit 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”
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”
static_asserthơ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ệnTô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
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
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ư
defercủa Go/Zig hoặc thuộc tínhcleanupcủa GCC, và hỗ trợ portable cho Unicode và UTF-8Việ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àmTuy vậy đã có đề xuất: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf