Vấn Đề Thiết Kế Giao Diện C
Vấn đề thiết kế giao diện C
C về bản chất chỉ hỗ trợ truyền tham số theo giá trị, không giống Pascal hay C++ có thể truyền theo tham chiếu. Đặc điểm này giúp C giữ được thiết kế đơn giản, nhưng đồng thời cũng gây nhiều tranh cãi.
Vì lý do hiệu năng, con trỏ buộc phải được đưa vào sử dụng. Có thể nói rằng hơn 90% lỗi trong phần mềm viết bằng C đều xuất phát từ việc sử dụng con trỏ. Tất nhiên, những vấn đề do thiết kế ngôn ngữ gây ra thậm chí còn nghiêm trọng hơn về mặt nguyên tắc.
Tranh luận về hiệu năng là việc của tầng thấp, trong khi C vốn là ngôn ngữ hoạt động ở tầng thấp. Khi chọn C, bạn bắt buộc phải quan tâm đến hiệu năng. Ngược lại, nếu cố gắng mô phỏng các tính năng của ngôn ngữ cấp cao hơn bằng C, đó chẳng khác nào vẽ rắn thêm chân. Giờ hãy xem một ví dụ thực tế về vấn đề truyền tham số này: Làm thế nào để thiết kế giao diện khi cần phân tích một khối dữ liệu nhị phân cấu trúc hóa và đưa vào sử dụng trong C?
Một đồng nghiệp gần đây đang xây dựng hệ thống tương tự Protocol Buffers. Việc thiết kế hệ thống như vậy không hề đơn giản, đặc biệt là khi phải tạo ra một ngôn ngữ chuyên dụng (DSL) phù hợp. Chúng tôi đã dành nhiều ngày để thảo luận và phân tích, nhưng hôm nay xin phép không đề cập đến thiết kế DSL. Thay vào đó, hãy bàn về một vấn đề nhỏ hơn: Khi đã phân tích xong dữ liệu nhị phân, làm thế nào để C có thể dễ dàng truy cập cấu trúc dữ liệu đó?
Với các ngôn ngữ cấp cao có cơ chế garbage collection (gc), đây không phải là vấn đề. Nhưng C lại thiếu khái niệm “đối tượng”. C có cấu trúc (struct), nhưng không hỗ trợ độ dài động; không có kiểu chuỗi, chỉ có mảng ký tự cố định; thậm chí không có mảng đa chiều, chỉ có mảng của mảng một chiều.
Hàm C có thể nhận hoặc trả về struct, nhưng trong thiết kế giao diện, do việc truyền giá trị và yêu cầu giữ gọn nhẹ ABI, người ta thường dùng con trỏ struct. Tuy nhiên, trả về con trỏ struct lại gây khó khăn về quản lý vòng đời biến. Ngay cả C++ cho phép trả về đối tượng cũng gặp vấn đề với kỹ thuật tối ưu trả về giá trị (RVO). Nếu bạn không quan tâm đến các chi tiết này, tại sao lại không chọn Java hoặc Python - những ngôn ngữ mạnh hơn nhiều so với C++ trong các trường hợp như vậy?
Các phương pháp phổ biến để trả về dữ liệu phức tạp
- Người gọi cấp phát bộ nhớ:
Phương pháp thông dụng nhất là yêu cầu người gọi cấp phát không gian trước, sau đó truyền cho hàm xử lý. Điều này cho phép người gọi chọn cấp phát trên stack hay heap. Một mẹo nhỏ: C cho phép truyền mảng như con trỏ, nên bạn có thể định nghĩa kiểu mảng struct độ dài 1 để code trông đẹp hơn (xem cách dùng setjmp trong thư viện chuẩn). Tuy nhiên, cá nhân tôi không khuyến khích việc lạm dụng đặc tính ngôn ngữ để tiết kiệm vài phím bấm.
Nhược điểm: Khó xử lý cấu trúc dữ liệu độ dài thay đổi, đặc biệt khi struct chứa tham chiếu đến struct khác. Ví dụ tương tự là cách đọc chuỗi bằng fgets - yêu cầu người gọi truyền buffer và kích thước (gets là thiết kế thất bại).
- Hàm tự cấp phát, người dùng giải phóng:
Tiêu biểu như strdup hoặc readline - thư viện dùng malloc để cấp phát, sau đó giao trách nhiệm giải phóng cho người gọi. Trong C, việc đồng nhất hóa quản lý bộ nhớ qua malloc không phức tạp như việc overload new trong C++. Tuy nhiên nhược điểm là: chỉ cấp phát từ heap, dễ gây rò rỉ bộ nhớ, và không xử lý được cấu trúc dữ liệu phức tạp do thiếu hàm hủy (destructor).
Một cách bổ sung cho phương pháp này là tích hợp cơ chế GC (thu gom rác). Dự án chúng tôi đang áp dụng thư viện GC nguồn mở, hiệu quả khá tốt. Nếu không tin tưởng GC, có thể dùng cơ chế tham chiếu đếm kiểu COM, nhưng lại vấp phải trở ngại do C thiếu khái niệm đối tượng.
- Cấp phát không gian tĩnh trong hàm:
Phương pháp này phổ biến, trả về con trỏ đến không gian tĩnh - dữ liệu tồn tại đến khi gọi lại hàm. Không gian này có thể đặt trong segment dữ liệu hoặc cấp phát từ heap lúc khởi tạo. Việc giải phóng không quan trọng vì hệ điều hành sẽ tự thu hồi sau khi chương trình kết thúc. Đây là tư tưởng “chương trình con gọn nhẹ” của UNIX.
Nhược điểm: Hàm không reentrant và gặp vấn đề an toàn luồng (thread-safe). Có thể dùng TLS (Thread Local Storage) để khắc phục, nhưng cá nhân tôi không khuyến khích dùng đa luồng trong C. Với nhiều tác vụ, hãy dùng đa tiến trình hoặc ngôn ngữ như Erlang.
Vấn đề cụ thể với hệ thống DSL của chúng tôi
Khi sinh code C từ DSL (mạnh hơn struct C), làm sao trả về cấu trúc dữ liệu dễ dùng? Giao diện C của MySQL là ví dụ điển hình. Nhiều lập trình viên C++ mới học thường “bao bọc” giao diện C bằng các template phức tạp như vector<map<…». Đây là việc làm phản cảm, giống như bắt xe đạp chạy trên cao tốc.
Thiết kế tối ưu nhất với C là: Người gọi truyền struct pointer, thư viện điền dữ liệu vào. Tuy nhiên với chuỗi hoặc mảng độ dài thay đổi, bắt buộc phải dùng con trỏ. Ví dụ, kỹ thuật đặt mảng độ dài 0 ở cuối struct chỉ giải quyết được trường hợp đơn giản.
Chúng tôi từng cân nhắc giải pháp kiểu C++: truyền memory allocator vào. Tuy nhiên, việc tự định nghĩa allocator thường chỉ vì hiệu năng, trong khi mục tiêu chính của lập trình là giải quyết độ phức tạp. Việc thêm hàm hủy hoặc con trỏ destructor lại càng làm hệ thống rối rắm.
Cuối cùng, chúng tôi chọn phương pháp:
- Người gọi cấp phát buffer liên tục
- Thư viện điền dữ liệu trực tiếp vào buffer
- Các dữ liệu tham chiếu (chuỗi, mảng…) được cấp phát trong cùng vùng nhớ
Ư