Lối Thiết Kế Phân Tầng Trong Ngôn Ngữ C - nói dối e blog

Lối Thiết Kế Phân Tầng Trong Ngôn Ngữ C

Chúng ta tiếp tục bàn luận về chủ đề thiết kế mô-đun trong lập trình. Đây là chuỗi bài viết tôi dự định phát triển dài hơi, tuy nhiên không nhất thiết theo trình tự tuần tự. Mỗi khi có cảm hứng, tôi sẽ ghi lại những suy nghĩ và định hướng để làm tư liệu cho việc tổng hợp sau này.

Trong thực tế, hệ thống mã nguồn có cấu trúc phân tầng luôn dễ bảo trì nhất. Bạn có thể thay thế bất kỳ thành phần nào ở tầng cụ thể nào đó mà không cần quá lo lắng về tác động phụ lan rộng toàn hệ thống. Điều này đặc biệt quan trọng trong các dự án quy mô lớn.

Những cạm bẫy thường gặp

  • Sự phụ thuộc vòng tròn giữa các mô-đun
    Đây là trường hợp nghiêm trọng nhất khi không xác định rõ mối quan hệ phụ thuộc giữa các module. Ví dụ, module A gọi module B trong khi B cũng phụ thuộc vào A. Cách xử lý duy nhất lúc này là phải xem chúng như một thực thể thống nhất, điều này vô hình chung phá vỡ nguyên tắc phân tầng. Ngoài ra còn phát sinh vấn đề thứ tự khởi tạo phức tạp.

  • Vi phạm nguyên tắc phân tầng
    Khi module A phụ thuộc vào B, mà B lại phụ thuộc vào C, thì tuyệt đối không nên để A trực tiếp gọi các hàm của C. Dù việc tuân thủ nghiêm ngặt đòi hỏi nhiều công sức (ví dụ như phải xây dựng lớp trung gian ở B để chuyển tiếp), nhưng đây là nguyên tắc cần được giữ vững để đảm bảo tính trong sáng của thiết kế.

Có một ngoại lệ hợp lý với các thư viện nền tảng như quản lý bộ nhớ, hệ thống logging hay thư viện chuỗi. Các thành phần này có thể được gọi trực tiếp từ nhiều tầng khác nhau, nhưng cần được che giấu kỹ ở các tầng cao hơn để tránh lộ diện.

Thực hành cụ thể trong ngôn ngữ C

Với đặc thù không có namespace, C yêu cầu chúng ta sử dụng tiền tố thống nhất cho các hàm API. Ví dụ module X sẽ có các hàm như X_init(), X_process()

Một module tiêu biểu thường xử lý một nhóm đối tượng liên quan. Có hai cách tiếp cận phổ biến:

  • Sử dụng handle kiểu số nguyên
  • Dùng con trỏ đến cấu trúc dữ liệu cụ thể

Dưới đây là mẫu khai báo interface cho module A:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#ifndef _A_h
#define _A_h

struct A;
struct B;

struct A* A_create(void);
void A_release(struct A *self);
void A_bind(struct A *self , struct B *b);
void A_commit(struct A *self);
void A_update(void);
int A_init(void);

#endif

Nguyên tắc thiết kế structure
Tôi luôn khuyến khích khai báo tường minh struct A thay vì dùng typedef. Điều này giúp người dùng luôn biết rõ mình đang làm việc với kiểu dữ liệu nào, đồng thời ẩn đi chi tiết triển khai bên trong file .c.

Hai nhóm hàm API

  • Hàm thành viên: Tham số đầu tiên là con trỏ self (tương đương this trong C++), ví dụ A_commit()
  • Hàm tĩnh: Xử lý toàn bộ các đối tượng cùng loại, như A_update()

Kết nối giữa các module
Khi A cần tham chiếu đến B, ta chỉ cần khai báo trước struct B trong file a.h thay vì include toàn bộ b.h. Điều này đảm bảo các file gọi đến a.h sẽ không bị “lây nhiễm” các khai báo không cần thiết từ B.

Giải pháp cho tham chiếu hai chiều
Trong trường hợp A và B cần tham chiếu lẫn nhau, tôi áp dụng kỹ thuật sau:

1
2
3
4
5
6
7
8
// Trong b.h
struct i_A;
void B_set_A(struct B *self, struct i_A *a);

// Trong a.c
static inline struct A* to_A(struct i_A *a) {
    return (struct A*)a;
}

Mấu chốt là tạo ra một kiểu “ẩn danh” i_A chỉ module A biết cách chuyển đổi. Điều này ngăn chặn việc tham chiếu vòng tròn trong khi vẫn đảm bảo tính kết nối.

Bài học thiết kế

  • Tránh tối đa các giải pháp “thông minh” phá vỡ nguyên tắc thiết kế. Những thủ thuật có vẻ tiện lợi lúc đầu có thể trở thành ác mộng khi bảo trì.
  • Luôn đặt câu hỏi về mối quan hệ phụ thuộc thực sự giữa các module. Cách tổ chức đúng sẽ giúp hệ thống vừa mạnh mẽ, vừa dễ mở rộng.

Thiết kế hệ thống giống như việc xây dựng một tòa nhà - nền móng vững chắc lúc nào cũng quan trọng hơn vẻ đẹp hình thức. Một quyết định thiết kế đúng đắn hôm nay sẽ tiết kiệm hàng trăm giờ sửa lỗi trong tương lai.

0%