Cách viết cấu trúc dữ liệu an toàn kiểu (generic) trong C
(danielchasehooper.com)- Bài viết này giải thích một phương pháp mới để tạo cấu trúc dữ liệu an toàn kiểu (generic) trong ngôn ngữ C
- Bài viết minh họa bằng cách triển khai linked list, với kỹ thuật liên kết thông tin kiểu với cấu trúc dữ liệu bằng
union - So sánh điểm khác biệt và nhược điểm của từng cách tiếp cận hiện có trong các mẫu generic của C (macro, con trỏ
void, Flexible Array Member) - Có thể kiểm tra kiểu tại thời điểm biên dịch, giúp ngăn chặn việc dùng sai kiểu từ sớm
- Kỹ thuật mới cung cấp giao diện hàm/cấu trúc dữ liệu rõ ràng và nhất quán như
foo_list
Giới thiệu
- Giới thiệu cách tạo cấu trúc dữ liệu generic trong C với tính an toàn kiểu
- Kỹ thuật này dùng
unionđể gắn thông tin kiểu với cấu trúc dữ liệu tại thời điểm biên dịch - Có thể áp dụng cho mọi cấu trúc dữ liệu như map, mảng, cây nhị phân..., và bài viết giải thích thông qua ví dụ triển khai linked list cơ bản
- Vì nhiều lập trình viên nghĩ rằng generic là bất khả thi trong C, bài viết trình bày dễ hiểu theo từng bước
Generic truyền thống dựa trên macro
- Cách triển khai cấu trúc dữ liệu generic truyền thống trong C là dùng macro để sinh tên struct, hàm và kiểu
- Có thể mở rộng bằng cách include cùng một header cấu trúc dữ liệu nhiều lần cho nhiều kiểu khác nhau
Ví dụ:
- Dùng macro (ví dụ:
CONCAT,NODE_TYPE,PREPEND_FUNC) để tạo tên struct và tên hàm phù hợp với từng kiểu - Hàm và struct được tạo riêng cho từng kiểu, nên với mỗi kiểu như
inthayFoosẽ có định nghĩa cấu trúc dữ liệu riêng
Nhược điểm:
- Khó xác định vị trí định nghĩa kiểu và hàm (vì được tạo ra bằng macro nên khó lần theo)
- Khó tận dụng tính năng tự động hoàn thành mã
- Việc tạo nhiều phiên bản của cùng một hàm làm tăng kích thước binary và thời gian build
- Tên hàm cần tiền tố kiểu (ví dụ:
Foo_list_prepend)
Generic bước 1: cách dùng con trỏ void
- Đặt kiểu dữ liệu của cấu trúc dữ liệu là
void *để tách khỏi kiểu cụ thể - Khai báo trường
datacủa linked list làvoid * - Không thể kiểm tra kiểu, nên lỗi kiểu có thể phát sinh lúc runtime; độ an toàn tại thời điểm biên dịch thấp
- Kém hiệu quả về bộ nhớ và cache: node và dữ liệu được cấp phát riêng, làm tăng overhead không cần thiết và số lần cache miss
Generic bước 2: lưu trữ inline (Flexible Array Member)
- Dùng Flexible Array Member (thành viên mảng linh hoạt) để lưu trực tiếp dữ liệu trong node thay vì lưu con trỏ
- Mỗi node chỉ cần một lần cấp phát, đồng thời dữ liệu và con trỏ
nextnằm gần nhau trong cache - Cách này cần truyền thông tin kích thước và dùng
memcpy, nhưng cải thiện hiệu năng nhờ bố cục bộ nhớ nhất quán - Nếu dùng hàm
list_alloc_frontthì có thể khởi tạo trực tiếp struct mà không cầnmemcpy
Generic bước 3: triển khai kiểm tra kiểu
- Khai báo con trỏ kiểu được tham số hóa trong thành viên
payloadcủaunionđể thêm thông tin kiểu vào cấu trúc dữ liệu tại thời điểm biên dịch - Ví dụ:
#define List(type) union { ListNode *head; type *payload; } - Cách này cho phép lấy kiểu của list tương ứng bằng
__typeof__(foo_list.payload) - Trong macro (
list_prepend), có thể ép kiểu hàm để chỉ biên dịch được khi kiểu là hợp lệ - Nếu dùng sai kiểu, lỗi sẽ xuất hiện ngay lúc biên dịch
Ví dụ lỗi:
- Khi thêm
intvàofoo_list, trình biên dịch sẽ báo lỗi'incompatible integer to pointer conversion'
Hỗ trợ cho trình biên dịch không có typeof
- Trước C23,
__typeof__chưa phải chuẩn, nên một số trình biên dịch (ví dụ: MSVC cũ) sẽ không hỗ trợ - Có thể đạt hiệu quả tương tự bằng các cách обход, chẳng hạn tận dụng thành viên
payloadtrongstruct
Truyền tham số và typedef
- Ngay cả khi cùng có dạng
List(Foo), trình biên dịch vẫn xem chúng là các kiểu khác nhau - Khi dùng
typedef, việc truyền tham số và gán giá trị sẽ trơn tru hơn
Ví dụ:
typedef List(Foo) ListFoo;- Có thể khai báo biến
ListFoovà dùng nó làm tham số hàm
Kết thúc và mở rộng sang nhiều cấu trúc dữ liệu
- Kỹ thuật này cũng có thể áp dụng cho các cấu trúc dữ liệu cần nhiều tham số kiểu (ví dụ: hash map)
- Có thể dùng
unionđể đảm bảo an toàn kiểu riêng chokeyvàvalue - Để xem thêm phần thực hành chi tiết và phần cài đặt macro, tham khảo liên kết gist mã nguồn liên quan
Kết luận
- Cách tiếp cận mới khắc phục các nhược điểm của phương pháp cũ (khả năng đọc, hiệu quả build, khả năng bảo trì), đồng thời mang lại quy ước đặt tên hàm nhất quán và tính an toàn kiểu
- Dễ hỗ trợ nhiều cấu trúc dữ liệu và nhiều tham số kiểu
- Thông qua kiểm tra kiểu tại thời điểm biên dịch, có thể đồng thời đảm bảo tính an toàn và hiệu quả khi dùng cấu trúc dữ liệu generic
Lời cảm ơn
- Bài viết này được hoàn thiện nhờ phản hồi và sự động viên từ Martin Fouilleul
2 bình luận
Tôi cũng có chút thắc mắc là chẳng phải chỉ cần dùng Zig một cách đơn giản là được sao?
Ý kiến trên Hacker News
Có ý kiến chỉ ra rằng ở mã bước 2, cách dùng
uint64_t data[];không phù hợp với các kiểu có yêu cầu căn chỉnh lớn hơnuint64_t, và ngược lại lại lãng phí không cần thiết với các kiểu nhỏ hơn; ví dụ trên ABI ilp32 của kiến trúc 64-bit thì còn rắc rối hơn. Với mã bước 3, có ý kiến cho rằng nên viếtint main() { List(Foo) foo_list = {NULL};như vậy. Khi không cótypeofthì không thể trả về giá trị, còn với mã thay thế thì có thể phát sinh lỗi liên quan đếnconst, và do tính đối xứng của toán tử==nên vấn đề này càng lộ rõ. Nếu bỏpayloadthì sẽ không còn thông tin kích thước nên không an toàn; ví dụ thêmint32_tvàoList(int64_t)thoạt nhìn có vẻ ổn nhưng thực tế không thể xác định kích thước củaint32_t. Muốn an toàn hơn thì cần bổ sung thêm. Người này cho rằng có hai giới hạn lớn khi dùng generic trong C: thứ nhất là cách ủy quyền qua vtable bị hạn chế chức năng vì struct không thể chứa macro; thứ hai là khi ủy quyền qua vtable bên ngoài thì phải khai báo trước toàn bộ các kiểu sẽ dùng. Cách tốt nhất là chỉ khai báo các hàm tĩnh trong header có chứa khai báo typedef, nhưng GCC và Clang lại khác nhau ở thời điểm đưa ra cảnh báo undefined static. Cuối cùng họ nêu ví dụ thiết kế hàm nhận các struct buffer khác nhau để nhấn mạnh rằng cần quản lý đầy đủ cả các phiên bảnconstVề vấn đề ủy quyền qua vtable bên ngoài, có người chia sẻ rằng trong một dự án cũ họ đã phải tự làm cả compiler để giải quyết chuyện này. Khi bắt đầu dự án Apache Clownfish, họ parse file .h rồi cuối cùng tạo ra luôn một định dạng riêng là header Clownfish (.cfh). Họ đưa ví dụ mã thật gọi phương thức "Clone" của obj, và kể lại trải nghiệm phải sinh hàng loạt đoạn mã quá tay như vậy để phục vụ binding cho ngôn ngữ động cần tính năng hướng đối tượng. Mục tiêu của Clownfish là cung cấp mô hình đối tượng mẫu số chung thấp nhất, và cả kiểu của ngôn ngữ binding cũng được sinh từ .cfh. Họ nói thêm rằng chính vì sự phức tạp này mà đa số cuối cùng đành từ bỏ tính an toàn kiểu bằng cách cast
void*. https://github.com/apache/lucy-clownfishVề
int main(), có người nhấn mạnh rằng trong C,int main()có nghĩa là số lượng đối số chưa xác định. Phải khai báoint main(void)thì mới có nghĩa là không có đối số. Đây là điều nhiều người viết C++ hay quênCó ý kiến mong rằng union thực sự có thể được “hợp nhất”, tức là một kiểu có thể tự khai báo mình là một phần trong union của kiểu khác
Có người chỉ ra rằng khi
malloc, do vấn đề padding nội bộ nên kích thước tính ra có thể nhỏ hơn thực tế; ví dụ khi làmmalloc(sizeof(*node) + data_size);thì có thể nguy hiểmCó người phản bác nội dung mẹo #0, nói rằng khi tự xây cả một phương ngữ C họ đã dùng chính mẹo này. Họ chia sẻ ví dụ mã hiện thực generic binary heap https://github.com/gritzko/librdx/blob/master/abc/HEAPx.h. Cú pháp hơi nặng, nhưng kết quả cuối cùng vẫn là một struct C thông thường nên rất có lợi về tối ưu hóa và tính dễ dự đoán. Theo họ, ở các cách hiện thực khác thì
void*, định cỡ bộ nhớ lúc chạy và định nghĩa macro là điều khó tránhVới tư cách tác giả, người viết giải thích rằng binary heap và linked list phục vụ mục đích khác nhau. Binary heap cần đọc dữ liệu khi lưu nên cách tiếp cận khác đi, và khi viết generic binary heap thì lựa chọn cũng có thể khác. Họ nói thêm là phần chú thích cuối bài cũng đã nhắc đến điều này
Có người nêu nhiều lý do thích cách hiện thực trong header. Khi debug thì dễ lần theo mã và tận dụng thông tin kiểu hơn so với hàm macro. Compiler có thể tối ưu monomorphized cho từng instance nên không có chi phí runtime hay gánh nặng kích thước biến đổi. Có thể đặt struct generic trên stack. Hai vấn đề mà tác giả nêu cũng có thể lách được: có thể dễ dàng đổi tên bằng macro tên hàm, và cũng có thể dùng weak symbol để khi liên kết thì các định nghĩa trùng tự động được gộp lại. Với container generic cho kiểu con trỏ thì lại có vấn đề khác, nhưng vẫn có thể xử lý bằng typedef v.v. Người này cho rằng intrusive data structure trong C vẫn tiện, dù debug thì khó hơn
Câu "compiler ngấu nghiến nó như ăn donut" khiến họ cười lớn
Có ý kiến nói rằng khi chuyển đổi kiểu hàm, ví dụ giả định biểu diễn nội bộ của
Foo*vàvoid*là giống nhau, thì chuẩn C không hề bảo đảm điều đó. Trong tình huống không có tính tương thích kiểu ("compatible"), kiểu cast như vậy có thể dẫn đến hành vi không xác định. Compiler cũng có thể bị ảnh hưởng trong các phân tích như alias analysis, v.v. (kèm liên kết tham khảo) https://news.ycombinator.com/item?id=44421185Có người hỏi: "Nếu phải làm đủ trò như vậy để có generics trong C, sao không dùng luôn C++?"
Có người chia sẻ kinh nghiệm rằng vì tiêu chuẩn an toàn và các yêu cầu khác nên nhiều dự án legacy không thể lập tức chuyển sang C++. Với dự án mới thì họ đặt chuẩn và đưa C++ vào, nhưng với dự án cũ thì trong một thời gian vẫn phải giữ C. Họ cho rằng góc nhìn kiểu "cứ dùng C++ đi" nên cảm thông hơn với bối cảnh thực tế
Trên thực tế, ở nơi đang dùng C, việc chuyển sang C++ có thể còn phức tạp hơn và gây ra nhiều vấn đề hơn
Ngược lại, cũng có quan điểm cho rằng chỉ cần bỏ ra chút công là có thể đạt cùng kết quả ngay trong C, nên không nhất thiết phải đi tới C++
Có người giới thiệu cách làm đang được dùng thực tế trong nhân Linux: nhúng
struct list_headchứa thông tin danh sách vào trong struct riêng của từng kiểu. Họ kèm liên kết tham khảo https://kernelnewbies.org/FAQ/LinkedListsLIST_HEAD_INITvàINIT_LIST_HEADtrong nhân Linux không thật sự trực quanCó người chỉ ra rằng trong mã ở phần "typeof on old compilers", dòng
(list)->payload = (item);thực ra không phải no-op mà sẽ khiến head của list bị ghi đè bằngitem. Nếu muốn đúng hành vi dự kiến thì nên bọc trongif(0)if(0)có vẻ tốt hơnCó người cho thấy trong ngôn ngữ D, kiểu generic list như vậy đơn giản hơn nhiều, và ví preprocessor của C như đóng đinh bằng búa vào móng tay, còn dùng nail gun thì vừa nhanh vừa gọn hơn, để nhấn mạnh sự bất tiện của macro C
Có người thấy ý tưởng dùng union và
typeof()rất thú vị. Với trường hợp của họ, khi làm data structure intrusive thì cuối cùng vẫn cần wrapper bọc trong các macro lớn; họ đặt câu hỏi liệu union vàtypeofcó thể dùng để hiện thực kiểu này không. Họ chia sẻ ví dụ mã wrapper cho hash table cùng tài liệu https://github.com/FRRouting/frr/blob/master/lib/typesafe.h#L823-L971 https://docs.frrouting.org/projects/dev-guide/en/latest/lists.htmlCó người chia sẻ rằng cá nhân họ đã dùng kỹ thuật này trong một thư viện thử nghiệm https://github.com/uecker/noplate/blob/main/src/list.h
Có ý kiến cho rằng cốt lõi là tận dụng kiểu của con trỏ hàm để đảm bảo an toàn kiểu, thay vì dùng kiểu handle như cách thường thấy. Trong chuẩn C23, vấn đề tương thích kiểu đã được cải thiện, và họ chia sẻ tài liệu chuẩn cùng tình hình hỗ trợ trên GCC/Clang mới nhất https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3037.pdf