- Datatype99 là một thư viện dựa trên macro, cung cấp kiểu dữ liệu đại số, pattern matching exhaustive và introspection tại thời điểm biên dịch trong C99 thuần
- Mục tiêu là bắt được tại thời điểm biên dịch các variant bị chỉ định sai kiểu, pattern matching bị thiếu nhánh, và truy cập trường sai; chỉ cần một trình biên dịch C99 tuân thủ chuẩn
- Bên trong,
datatype được mở rộng thành tagged union và value constructor, còn match được mở rộng thành câu lệnh switch; layout dữ liệu được sinh tuân theo ngữ nghĩa sinh mã đã được hình thức hóa
- Cài đặt bằng cách thêm
datatype99.h và phụ thuộc Metalang99 vào include path; với GCC/Clang, nên dùng các tùy chọn biên dịch để giảm lượng thông báo lỗi do mở rộng macro
- Có thể tích hợp vào codebase C bằng
#include <datatype99.h>; được biết là hoạt động trên GCC, Clang, MSVC, TCC và cũng hỗ trợ C++11 trở lên
Datatype99 cung cấp những gì
- Datatype99 cung cấp kiểu dữ liệu đại số an toàn và trực quan trong C99
- Phạm vi tính năng bao gồm cả pattern matching exhaustive và introspection tại thời điểm biên dịch
- Hoạt động mà không cần công cụ sinh mã bên ngoài; phần triển khai dựa trên C99 thuần
- Các đặc điểm chính
- An toàn kiểu: bắt tại thời điểm biên dịch các variant bị chỉ định sai kiểu, pattern matching không exhaustive, và truy cập trường sai
- Tính di động: cần trình biên dịch C99 tuân thủ chuẩn, không cần thư viện chuẩn, tính năng đặc thù của trình biên dịch/nền tảng, hay VLA
- Tính dự đoán được: ngữ nghĩa sinh mã đã được định nghĩa, đảm bảo layout dữ liệu được sinh luôn giống nhau
- Lỗi dễ hiểu: bản thân thư viện phát hiện một số lỗi cú pháp trong mã sai
- Trường hợp sử dụng thực tế: được dùng trong OpenIPC để phát triển phần mềm streaming thời gian thực cho camera IP, bao gồm triển khai RTSP 1.0 và khoảng 50 nghìn dòng mã không công khai
Cài đặt và cấu hình build
- Datatype99 gồm một file header
datatype99.h và phụ thuộc Metalang99
- Để dùng trong dự án, cần thêm
datatype99 và metalang99/include vào các thư mục include
- Với GCC, nên chỉ định
-ftrack-macro-expansion=0; với Clang, nên chỉ định -fmacro-backtrace-limit=1 để giảm lượng thông báo lỗi mở rộng macro không cần thiết
- Khi dùng CMake, khuyến nghị dùng
FetchContent
datatype99/CMakeLists.txt mặc định tải Metalang99 v1.13.5 từ GitHub Releases
- Có thể ghi đè hành vi này bằng cách gọi
FetchContent_Declare trước đó
- Các header phụ thuộc vào Datatype99 có thể được tạo thành precompiled header, giúp giảm thời gian biên dịch vì không phải biên dịch lại mỗi lần được include
Cách dùng: sử dụng tagged union an toàn hơn
- Về cơ bản, Datatype99 là syntax sugar cho tagged union, cung cấp dạng an toàn và ngắn gọn hơn
- Trong C thông thường, để biểu diễn cây nhị phân, cần tự viết enum
tag và union
- Với Datatype99, có thể khai báo cùng cấu trúc như sau
datatype(
BinaryTree,
(Leaf, int),
(Node, BinaryTree *, int, BinaryTree *)
);
- Với cách dùng
switch thông thường, dù sau case Leaf: bạn vô tình truy cập tree->data.node, trình biên dịch có thể không cảnh báo
- Khi dùng
match và of, các binding theo từng variant chỉ xuất hiện trong nhánh tương ứng; nếu truy cập không phù hợp, quá trình biên dịch sẽ thất bại
int sum(const BinaryTree *tree) {
match(*tree) {
of(Leaf, x) return *x;
of(Node, lhs, x, rhs) return sum(*lhs) + * x + sum(*rhs);
}
return -1;
}
- Các binding do
of cung cấp là các biến như x, lhs, rhs; chúng có kiểu con trỏ nên có thể thay đổi giá trị
- Việc tạo variant dùng các value constructor được sinh bên trong
BinaryTree leaf5 = Leaf(5);
BinaryTree leaf7 = Leaf(7);
BinaryTree node = Node(&leaf5, 123, &leaf7);
Cú pháp và ngữ nghĩa sinh mã
- Datatype99 cung cấp cú pháp macro như
datatype, record, match, of, otherwise, MATCHES, ifLet
- Có cả tên macro rút gọn và phiên bản có hậu tố
- Ví dụ:
match99, of99, derive99
- Để tránh xung đột tên, có thể định nghĩa
DATATYPE99_NO_ALIASES trước khi include datatype99.h
- Header thư viện được khuyến nghị dùng macro có hậu tố
datatype sinh ra các thành phần sau
- forward
typedef
- struct cho từng variant không rỗng
typedef cho kiểu trường của variant
typedef cho sum type
- tagged union gồm enum
tag và union
- value constructor
inline static cho từng variant
- lời gọi deriver được chỉ định trong
derive(...)
- Ngay cả khi mọi variant đều rỗng, do chuẩn C yêu cầu union có ít nhất một mục,
char dummy; sẽ được thêm vào
record là struct có derivation process được định nghĩa; ngay cả khi không có trường cũng sinh char dummy;
match so sánh một instance sum type lần lượt với các variant, thực thi câu lệnh của nhánh khớp rồi chuyển sang lệnh tiếp theo
- Cú pháp
match và ifLet hoàn chỉnh mỗi cái được mở rộng thành một câu lệnh C
MATCHES kiểm tra đúng/sai xem một instance sum type có thuộc một variant cụ thể hay không
matches đã deprecated và khuyến nghị dùng MATCHES
derive và thuộc tính hỗ trợ
derive(...) được dùng để sinh mã toàn cục cho sum type hoặc record
- Deriver của sum type được gọi dưới dạng macro tương thích Metalang99, và danh sách variant được truyền dưới dạng tuple list
- Deriver của record cũng được gọi dưới dạng macro tương thích Metalang99, và danh sách trường được truyền dưới dạng tuple list có dạng
(<type>, <field-name>)
- derive helper attribute là đối số có tên được truyền cho deriver
- helper attribute sử dụng dạng object-like macro
#define <variant-name>_<namespace>_<attribute-name> attr(/* attribute value */)
- Các macro thao tác thuộc tính được cung cấp
DATATYPE99_attrIsPresent / DATATYPE99_ATTR_IS_PRESENT: kiểm tra thuộc tính có tồn tại hay không
DATATYPE99_attrValue / DATATYPE99_ATTR_VALUE: trích xuất giá trị của thuộc tính hiện có
DATATYPE99_assertAttrIsPresent: phát sinh fatal error nếu thiếu thuộc tính bắt buộc
Các mẫu sử dụng cần chú ý
- Không được dùng
break/continue ở cấp trên cùng trong các câu lệnh truyền cho of và ifLet
continue bên trong vòng lặp for/while nội bộ thì được
- Luồng điều khiển cấp trên cùng nên được thay bằng label
goto
- Nếu muốn chỉ định mảng làm tham số variant, cần đặt nó vào một
struct riêng
- Binding do
of đưa vào luôn mutable, nên nếu giá trị truyền cho match là const thì cần cẩn thận để không sửa đổi
- Để giữ định nghĩa
datatype dễ đọc, có thể dùng // clang-format off và // clang-format on trong Clang-Format
- derive helper attribute phải luôn được
#undef sau định nghĩa datatype tương ứng để tránh làm ô nhiễm namespace
- Nếu ý nghĩa của tham số variant không rõ chỉ từ ngữ cảnh, có thể dùng type alias hoặc struct riêng để đặt tên mô tả hơn
Lỗi, IDE và khả năng tương thích
- Một số lỗi cú pháp được chính thư viện phát hiện
- Dạng không phải tuple như
Bar(int)
- Thiếu dấu phẩy
- Trailing comma bị cấm
- Các lỗi khác cũng được lộ ra qua chẩn đoán thông thường của trình biên dịch
- Tên kiểu không tồn tại
match không exhaustive
- Quá nhiều binder trong
of
- Đối số variant bị chỉ định sai kiểu
- Trả về binding con trỏ mà không dereference
- Theo kinh nghiệm, gần 95% lỗi được cho là hiển thị có ý nghĩa
- Nếu lỗi khó hiểu, có thể kiểm tra mã được sinh bằng
-E; vì ngữ nghĩa sinh mã được định nghĩa hình thức, thường sẽ không xuất hiện mã ngoài dự kiến
- VS Code tự động bật gợi ý cho các kiểu được sinh, nhưng không hỗ trợ tô sáng cú pháp macro
- Datatype99 được biết là hoạt động trên GCC, Clang, MSVC, TCC
- Cũng hỗ trợ C++11 trở lên
Lý do nhắm đến C và các giới hạn
- Phần mềm hiện có được viết bằng C thuần có thể hưởng lợi từ Datatype99
- Có thể tích hợp vào codebase C hiện có chỉ bằng
#include <datatype99.h>
- Một số môi trường vẫn duy trì C thuần vì lý do lịch sử, như thiết bị nhúng, Linux và các hệ điều hành khác
- ABI ổn định của C quan trọng với các dự án hệ thống plugin như MetaCall
- C được trình bày như một ngôn ngữ trưởng thành, có đặc tả đầy đủ và nhiều thư viện
- Nếu có thể dùng ngôn ngữ hiện đại hơn hoặc cấp cao hơn thì nên dùng chúng thay cho C cũ, nhưng với nhiều người lựa chọn đó không tồn tại hoặc có chi phí lớn
- Khác biệt giữa Datatype99 và Metalang99 nằm ở vai trò
- Metalang99 là một ngôn ngữ hàm dùng cho metaprogramming
- Datatype99 là phần triển khai kiểu dữ liệu đại số được viết bằng Metalang99
1 bình luận
Ý kiến trên Hacker News
Trong công việc tôi phải dùng Java, và tôi đã thấy Java không tệ như những gì nó từng bị chỉ trích, nhưng đã có hàng chục lần tôi nghĩ “giá mà Java có union phân biệt (discriminated union) như F#”
Có thể mô phỏng bằng nhiều kỹ thuật, và cũng có nhiều trường hợp chỉ enum là đủ, nhưng đa phần vẫn thiếu sự linh hoạt và ngắn gọn của kiểu dữ liệu đại số đúng nghĩa. Quan trọng nhất là không có pattern matching tuyệt vời có được trong các ngôn ngữ hàm
Phần mở rộng C này trông có vẻ có pattern matching như mong muốn nên khá ổn, tôi sẽ phải xem liệu có dùng được trong dự án Arduino không
Người vừa mới biết đến thì sẽ không ngừng nói rằng đây là phát minh tuyệt vời nhất kể từ bánh mì cắt lát :)
Java có https://github.com/functionaljava/functionaljava, tuy đã ngừng hỗ trợ nhưng ổn định
Nó quá mạnh, thiếu nó là không được
Gần đây đã có hỗ trợ pattern matching do trình biên dịch cưỡng chế trên sealed class, nên có vẻ như đã đi được nửa đường
Nếu muốn xem kiểu dữ liệu đại số vận hành bên trong như thế nào, tôi nghĩ libsum là tài liệu nhập môn tốt
[1] https://github.com/naasking/libsum
Tôi đã biết C gần 20 năm, nhưng chưa từng nghĩ hệ thống macro lại mạnh đến mức cho phép thứ ma thuật hắc ám như thế này
Thật sự rất tuyệt
Theo truyền thống, nó được dùng để tạo và truy cập generic an toàn kiểu, hoặc để giảm mã lặp trong định nghĩa thanh ghi phần cứng và ngắt
Có cảm giác hơi bị nguyền rủa, nhưng cốt lõi thực ra rất đơn giản, và là một công cụ đáng tin cậy để giảm độ phức tạp nhận thức và mã lặp trong các dự án nền C
Xét về cách dùng macro thì nó không quá phức tạp
https://en.wikipedia.org/wiki/Tagged_union#Class_hierarchies...
Cũng có một bài blog dài bàn về cùng nội dung, nhưng có vẻ tác giả vẫn chưa thấy phần đó trên Wikipedia
https://nandakumar.org/blog/2023/12/paradigms-in-disguise.ht...
Đáng khen là nhà phát triển datatype99 đã chỉ ra ngay trong README vấn đề của kiểu giải pháp tạm bợ này
https://github.com/melt-umn/ableC-template-algebraic-data-ty...
Dù vậy, ngoài điểm đó ra thì rất tuyệt
Tôi từng làm UI immediate mode bằng macro và chuyện này làm tôi nhớ lại. Trong trường hợp của tôi thì không thành vấn đề, nhưng ở một số block có thể dùng "break"
Ví dụ trong block win_form có thể thoát ra bằng "break" và "goto" cũng hoạt động. Ngược lại, block win_command không bắt "break", nên nếu dùng break hoặc goto trong win_command thì sẽ thoát khỏi block bên ngoài bao quanh win_command đó, có lẽ là block win_form. Thường dùng cho những trường hợp như nút "Cancel"
Nó không chỉ tốt hơn đôi chút ở vài mặt, mà là làm mượt đi vô số bất tiện nhỏ tích tụ theo thời gian trên toàn hệ sinh thái. Giống như việc họ CPython có quá nhiều kiểu từ vựng riêng, hay các thư viện tính toán hiệu năng cao cũng vậy; hiệu ứng tích lũy là rất lớn
Chẳng hạn, phần gây ra vấn đề kiểu này trong macro này đã không trở thành vấn đề ngay từ đầu nếu đó là một macro Rust được viết tốt. Nó là sản phẩm phụ sinh ra khi những người thông minh cố gắng обход qua các giới hạn của C
Tuy nhiên bản thân macro này vốn cũng là bản chuyển thể một tính năng native của Rust, nên ngay từ đầu trong Rust có lẽ đã không cần viết nó, và vì vậy nó được tận dụng một cách tự nhiên trong phần mềm cộng đồng
gotochỉ là thứ dễ tự bắn vào chân khi dùng để nhảy từ hàm này sang hàm khác; thực ra “goto considered harmful” cũng nhắm vào thực hành đóThực hành ấy đã biến mất, và goto dùng bên trong hàm hiện nay khá vô hại, trên thực tế gần như giống
break/continueVà giả sử bạn cũng đã biết có Rust, nhưng sẽ không dùng Rust vì những lý do mà ai viết chương trình C cũng biết
Dù vậy, ít nhất Zig cũng đáng để cân nhắc. Hai ngày trước tôi đã viết đoạn code kiểu này bằng Zig
Dùng
inline elsecủacomptimeđể sinh tất cả các nhánh của câu lệnhswitchtrên tagged union, và cộng offset cho các thành viên union có trường"l". Nếu là thông tin kiểu có thể biết tại thời điểm biên dịch, bạn có thể thay đổi tính chất của các nhánh theo nhiều cách, và lượng thông tin đó khá nhiều. Vì mọi điều kiện đều được quyết định tại thời điểm biên dịch, mỗi nhánh củaswitchchỉ có phần logic cần thiết để xử lý biến thể tương ứng“Nhưng chương trình của tôi đã là C rồi và chỉ cần trong một file thôi” thì dù vậy, bạn cũng nên thử Zig. Biết đâu lại thích
https://gist.github.com/unclechu/eb37cc81e80afbbb5e74990b62e...
struct+union+enumlà có thể nhận được phần lớn lợi ích của kiểu dữ liệu đại số sao? Tôi từng dùng pattern đặt mộtuniongồm nhiều kiểu rồi gắn thêm mộtenumđể phân biệt chọn cái nàostd::variantdường như cũng hoạt động phần nào như sum typeVấn đề duy nhất là không thể tạo câu lệnh
switchgọn gàng theo một giá trị cụ thể của trường, nhưngswitchlồng nhau cũng không bừa bộn đến thếvtable, nên thường xuyên phải đi xuống chi tiết triển khaiVấn đề của cách tiếp cận này là gánh nặng tinh thần để chăm hết mọi chi tiết là rất lớn. Vì mệt mỏi, bạn bắt đầu nhét ép chức năng vào class hiện có thay vì tạo class mới
Cuối cùng bạn cảm thấy abstraction là thứ đắt đỏ, và kết quả là dùng abstraction ít hơn dù có thể giải quyết vấn đề một cách thanh nhã hơn
Tóm lại, khả năng nói “Darmok and Jalad at Tanagra” thay vì kể lại một câu chuyện dài mỗi lần muốn tham chiếu đến một ý nghĩ phức tạp là điều mang tính biến đổi
Pattern matching mới là lợi ích lớn về khả năng sử dụng
Làm bằng macro thì khó, nhưng không phải không thể. Về cơ bản cần một macro để bắt đầu ngữ cảnh matching, phải đưa vào một biến để theo dõi mọi phương án đã kiểm tra, và phải cấu trúc sao cho khi ngữ cảnh matching kết thúc thì kiểm tra xem đã xét hết mọi phương án chưa
std::variantlắmCách dùng
std::visitđể match đầy đủ mọi trường hợp có cảm giác hacky. Nếu nó thật sự trở thành tính năng ngôn ngữ hạng nhất thì sẽ rất có lợiNó có thể ảnh hưởng đến code hằng ngày còn lớn hơn những tính năng khác mà họ đã làm trong thời gian qua, như coroutine