Thiếu Sót Trong Khả Năng Hỗ Trợ Phân Mảnh Của Ngôn Ngữ C
Tiếp nối chủ đề ngày hôm qua. Xin chia sẻ một số nội dung tiềm năng cho cuốn sách tương lai để bàn luận. Khi chủ đề chính của cuốn sách này là “Làm thế nào để xây dựng một phần mềm quy mô vừa phải”, và tác giả chọn C làm công cụ hiện thực hóa, chúng ta không thể lờ đi những gì mà ngôn ngữ chưa cung cấp.
Nguyên tắc phân mảnh (modularity) là một trong những nguyên tắc tối thượng (cuốn “Nghệ thuật lập trình Unix” từng xác định nguyên tắc đầu tiên của triết lý Unix chính là nguyên tắc phân mảnh). Vậy hãy cùng suy nghĩ xem làm thế nào để triển khai tính phân mảnh một cách tinh gọn bằng ngôn ngữ C.
Nếu không phải C/C++, khó có thể hình dung một ngôn ngữ lập trình phổ biến hiện đại nào lại thiếu cơ chế quản lý phân mảnh chuẩn hóa. Tuy nhiên đây cũng chính là triết lý thiết kế đặc trưng của C: để lại càng nhiều khả năng linh hoạt càng tốt cho lập trình viên, từ đó họ có thể tùy biến giải pháp phù hợp với hệ thống và nhu cầu thực tế.
Với các hệ thống khổng lồ (ví dụ như hệ điều hành Windows), thông thường sẽ áp dụng giải pháp phân mảnh ở cấp độ nhị phân. Các mô-đun tự cung cấp metadata hoặc sử dụng cơ chế quản lý tập trung (như registry). Trong khi đó các hệ thống nhỏ hơn (những thứ chúng ta thường gặp), sẽ ưu tiên giải pháp nhẹ nhàng hơn ở cấp nguồn (source code).
Điều đầu tiên cần quan tâm chính là mối quan hệ phụ thuộc và quá trình khởi tạo giữa các mô-đun.
Về mặt phụ thuộc, có thể giải quyết bằng linker/loader. Đặc biệt khi dùng C, các thư viện tĩnh hay động đơn giản thường không gây ra nhiều rắc rối.
Tuy nhiên với C++ lại khác. Một số tính năng của C++ (như constructor của static member trong template class) đòi hỏi phải mở rộng khả năng của linker truyền thống dùng cho C. Ngay cả các thư viện C++ được viết cẩn trọng cũng có thể gặp lỗi bất ngờ, mà đôi khi cần hiểu biết chuyên sâu về quá trình biên dịch, liên kết và tải mới có thể gỡ rối. (Ghi chú: Mục này không nhằm bất kỳ phản đối nào với việc dùng C++ phát triển)
Điểm cần tập trung quản lý chính là quá trình khởi tạo của các mô-đun.
Với các thư viện tích hợp sẵn (như glibc hay msvcrt), chúng ta biết rằng sẽ có điểm vào khởi tạo khi tải và đoạn mã kết thúc khi gỡ bỏ. Nhưng điều tôi muốn nói ở đây là các mô-đun nhỏ do chúng ta tự phân tách, với các mối quan hệ phụ thuộc lẫn nhau.
Ai khởi tạo trước, ai khởi tạo sau – đó chính là câu hỏi then chốt.
Trong giải pháp cấp ngôn ngữ C++, người ta dùng mô hình đơn thể (singleton). Cách tiếp cận có thể để linker quyết định thứ tự sinh mã khởi tạo, nhưng điều này thường dẫn đến lỗi khi thứ tự xây dựng thực tế không khớp với phụ thuộc logic (Ghi chú: Tôi từng gặp nhận định này trong vài cuốn sách C++, cần xác minh thêm – lâu rồi chưa tiếp xúc C++ nên không có ví dụ cụ thể). Giải pháp thay thế là khởi tạo lười biếng (lazy initialization), tuy nhiên giải pháp này cũng không phải toàn năng và tạo thêm chi phí nhất định (đặc biệt trong môi trường đa luồng).
Trong giai đoạn thiết kế bằng C, tôi áp dụng phương pháp đơn giản hiệu quả sau: Thiết lập quy tắc mã hóa rằng mỗi mô-đun phải có hàm khởi tạo với tên chuẩn. Ví dụ như mô-đun foo sẽ có điểm vào:
int foo_init()
Quy tắc: Bất kỳ mô-đun nào sử dụng thành phần khác đều phải gọi hàm khởi tạo của mô-đun phụ thuộc.
Để tránh việc khởi tạo trùng lặp, hàm khởi tạo sẽ không được gọi trực tiếp, mà thông qua trung gian:
mod_using(foo_init);
Hàm mod_using
đảm nhận vai trò gọi hàm khởi tạo, bảo đảm không gọi hai lần và phát hiện vòng phụ thuộc. Đồng thời chúng ta cũng quy ước giá trị trả về của hàm khởi tạo (trong hệ thống này, 0 là thành công, 1 là thất bại). Từ đó định nghĩa macro hỗ trợ:
#define USING(m) if (mod_using(m##_init,#m)) { return 1; }
(Ghi chú: Cá nhân tôi phản đối việc lạm dụng macro, luôn cố gắng hạn chế. Việc dùng macro ở đây là cân nhắc kỹ lưỡng. Tôi hy vọng có công cụ quét mã có thể phát hiện trường hợp quên gọi hàm khởi tạo – macro sẽ giúp công cụ phân tích dễ dàng hơn. Hơn nữa, macro giống như một mở rộng nhỏ cần thiết cho ngôn ngữ).
Với cách này, mã nguồn của mỗi mô-đun sẽ chứa hàm init đơn giản gọi các macro USING để tham chiếu mô-đun phụ thuộc:
#include “module.h” /* Tôi thích include module.h vào cuối file nguồn, ngay trước điểm vào khởi tạo. File này chỉ chứa định nghĩa macro USING và các hàm quản trị liên quan. Cách sắp xếp này giúp tránh việc nhúng các mô-đun khác ở các phần code không mong muốn */
int foo_init() {
USING(memory); // Tham chiếu mô-đun quản lý bộ nhớ
USING(log); // Tham chiếu mô-đun ghi log
return 0;
}
Về vấn đề gỡ bỏ mô-đun, đa số các hệ thống không thực sự cần thiết. Phần này hôm nay xin phép không đi sâu luận giải.