Phong Cách Lập Trình Hướng Đối Tượng Bằng Ngôn Ngữ C Mà Tôi Ưa Chuộng - nói dối e blog

Phong Cách Lập Trình Hướng Đối Tượng Bằng Ngôn Ngữ C Mà Tôi Ưa Chuộng

Lập trình hướng đối tượng không phải là “viên đạn bạc” giải quyết mọi vấn đề. Trong đa số trường hợp, tôi luôn thận trọng khi sử dụng OOP - nếu có thể tránh được thì sẽ không dùng. Những tranh luận sâu hơn về chủ đề này xin phép không trình bày ở đây.

Tuy nhiên, trong một số tình huống đặc thù như xây dựng framework giao diện người dùng hay quản lý cảnh trong engine render 3D, việc áp dụng OOP thực sự mang lại hiệu quả cao. Mặc dù C không hỗ trợ OOP ở cấp độ ngôn ngữ, nhưng điều này không đồng nghĩa với việc C không phù hợp để xây dựng chương trình theo mô hình hướng đối tượng. Ngược lại, chúng ta lại có nhiều lựa chọn linh hoạt hơn trong việc thiết kế cơ chế triển khai.

Nhiều lập trình viên khi viết OOP bằng C thường chịu ảnh hưởng sâu sắc từ C++. Họ cố gắng dùng macro để mô phỏng mô hình đối tượng mà compiler C++ đã xây dựng sẵn. Theo quan điểm cá nhân tôi, đây không phải là hướng đi tối ưu. Mô hình đối tượng của C++ về bản chất được thiết kế để tối ưu hiệu năng thực thi, điều này thể hiện rõ qua việc濫用 các hàm inline - dù hiệu quả nhưng lại phá vỡ nguyên tắc tách biệt giữa giao diện và triển khai. Hệ thống kế thừa trong C++ tạo ra sự ràng buộc quá chặt chẽ giữa các thành phần.

Theo cách hiểu của tôi, cốt lõi của lập trình hướng đối tượng là tạo ra cơ chế thao tác thống nhất cho các đơn vị dữ liệu khác nhau. Khi các dữ liệu có chung cách xử lý, chúng ta có thể xử lý chúng theo nhóm. Một đơn vị dữ liệu có thể thuộc nhiều nhóm khác nhau tùy theo đặc tính chung được khai thác. Những đặc tính chung này được gọi là Interface - trong C thể hiện qua tập hợp các con trỏ hàm, tương đương với khái niệm vtable trong C++.

Mô hình triển khai hướng đối tượng bằng C mà tôi ưa chuộng như sau:

Giả sử có một nhóm dữ liệu cần thể hiện đặc tính chung tên là foo. Ta gọi các dữ liệu này là foo_object. Thông thường sẽ có các API cơ bản để quản lý foo_object:

1
2
3
4
struct foo_object; 
struct foo_object * foo_create(); 
void foo_release(struct foo_object *);
void foo_dosomething(struct foo_object *);

Trong file triển khai foo.c, ta định nghĩa cấu trúc foo_object chứa các thành viên dữ liệu cần thiết cho hàm foo_dosomething. Tuy nhiên, mô hình này chưa đủ linh hoạt khi gặp các dữ liệu chỉ thể hiện một phần đặc tính foo. Lúc này cần định nghĩa một interface nội bộ cho foo.c sử dụng:

1
2
3
4
struct i_foo { 
    void (*foobar)(void *); 
}; 
struct foo_object * foo_create(struct i_foo *iface, void *data);

Giải thích chi tiết: Interface i_foo chứa các hàm cần thiết cho foo_dosomething. Khi tạo foo_object, ta truyền vào một con trỏ dữ liệu data và interface i_foo tương ứng. Mỗi đối tượng phù hợp với foo_object sẽ có phương thức riêng để lấy i_foo tương ứng:

1
2
3
4
struct foobar; 
struct i_foo * foobar_foo(void); 
struct foobar * foobar_create(void); 
void foobar_release(struct foobar *);

Quy trình tạo đối tượng foo_object sẽ như sau:

1
2
struct foobar *foobar = foobar_create();
struct foo_object * fobj = foo_create(foobar_foo(), foobar);

Cấu trúc foo_object phải chứa con trỏ interface i_foo và dữ liệu data. Nếu nhìn qua lăng kính C++, foo_object giống như lớp cơ sở với các thành viên và hàm không ảo. Các lớp dẫn xuất sẽ ghi đè vtable i_foo và mở rộng dữ liệu qua con trỏ data. Cách tiếp cận này sử dụng composition thay vì inheritance, tạo ra mức độ trừu tượng cao hơn nhưng giảm coupling giữa các thành phần.

Cấu trúc tiêu biểu của foo_object:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct foo_object { 
    struct i_foo * vtbl; 
    void * data; 
    void * others; 
};

void foo_dosomething(struct foo_object *fobj) { 
    fobj->vtbl->foobar(fobj->data); 
    // Thực hiện các thao tác khác
}

Vấn đề quan trọng cần giải quyết: Ai chịu trách nhiệm quản lý vòng đời của data?

Quản lý vòng đời là một chủ đề phức tạp, đồng thời là nguồn gốc của nhiều độ phức tạp trong các hệ thống C/C++. Cá nhân tôi thường tách riêng vấn đề này thành một lớp quản lý độc lập. Module foo_object chỉ chịu trách nhiệm giải phóng tài nguyên của struct foo_object, không can thiệp vào vòng đời của data.

Triết lý phát triển phần mềm bằng C của tôi là “tự quản lý lấy mình”. Tôi thường kết hợp C với các ngôn ngữ khác như Lua hoặc C++ để giải quyết vấn đề này. Trong trường hợp không dùng đa ngôn ngữ, tôi sẽ xây dựng một tầng quản lý vòng đời riêng bằng chính C, chi tiết sẽ trình bày trong bài viết tiếp theo.

Việc tách biệt quản lý vòng đời giúp giảm đáng kể lượng code và hạn chế lỗi phát sinh.

P/S: C là ngôn ngữ có hệ thống kiểu yếu hơn C++. Điều này thể hiện ở:

  • void* có thể trỏ đến bất kỳ kiểu dữ liệu nào, cho phép gán tự do giữa các con trỏ khác kiểu (điều này sẽ gây cảnh báo trong C++)
  • Các con trỏ hàm khác kiểu gán cho nhau sẽ gây cảnh báo, nhưng void* có thể dùng như cầu nối
  • Trong C, void (*foo)() có thể nhận địa chỉ hàm void foobar(int) nhờ quy tắc truyền tham số của chuẩn C

Lưu ý khi lập trình C: Nếu muốn compiler kiểm tra lỗi truyền tham số thừa, phải khai báo hàm rõ ràng void foo(void) trong file .h

Khởi tạo cấu trúc trong C truyền thống cần cẩn trọng. Việc định nghĩa interface i_foo ở trên sử dụng cấu trúc C đòi hỏi sự chính xác cao. C99 đã cải thiện đáng kể qua cú pháp khởi tạo theo tên thành viên, không phụ thuộc thứ tự - nên áp dụng để tăng tính rõ ràng và an toàn.

0%