genealogy
genealogy

C không phải là ngôn ngữ lập trình hướng đối tượng. Vậy vận dụng lập trình hướng đối tượng cho ngôn ngữ C như thế nào? Có những ngôn ngữ hướng đối tượng, kế thừa cú pháp của C như C++, Objective C. Tuy nhiên bài viết này chỉ chia sẻ cách áp dụng cho C, một ngôn ngữ không hỗ trợ cho lập trình hướng đối tượng.

Mục tiêu của bài viết.

Bài viết này không phải là tutorial, cũng không phải là chia sẻ kiến thức theo kiểu “sách giáo khoa”. Bài viết này chỉ chia sẻ về việc vọc code của mình. Việc vọc code này chỉ giúp chúng ta dễ hình dung hơn về OOP. Chính vì vậy, mình không khuyến khích các bạn áp dụng những chia sẻ này vào thực tế.

Ví dụ

Một bài tập hướng đối tượng cơ bản. Cho sơ đồ class sau.

class diagram
class diagram

Thực hiện:

  • Implement các class có trong sơ đồ
  • Viết chương trình tạo một danh sách các hình bao gồm một hình tam giác, một hình chữ nhật, một hình vuông, sau đó in ra diện tích các hình có trong danh sách.

Implement trong Java – ngôn ngữ OOP

Áp dụng trong ngôn ngữ C

Một số khái niệm thường thấy trong một ngôn ngữ OOP

Lớp (class)

Mình dùng c struct để biểu diễn thay cho class.

Đối tượng (object)

Trong ngôn ngữ OOP, người ta thường dùng biến để đại diện cho đối tượng. Trong ví dụ này (ngôn ngữ C), mình dùng biến con trỏ để đại diện cho đối tượng.

Phương thức (Method)

Mình dùng con trỏ hàm để thay thế cho phương thức của đối tượng.

Đối tượng this

Ngôn ngữ C không có đối tượng this. Do đó, mình dùng tham số đầu tiên (kiểu con trỏ) trong hàm để thay thế cho đối tượng this.

Constructor

Mình dùng 1 hàm để biểu diễn cho constructor. Tham số đầu tiên luôn là con trỏ. Nếu con trỏ NULL thì hàm này sẽ cấp phát vùng nhớ mới rồi thực hiện tiếp. Nếu con trỏ khác NULL thì hàm sẽ sử dụng vùng nhớ mà con trỏ trỏ đến (Trong trường hợp vùng nhớ đã được cấp phát ở class con).

Các tính chất của OOP

Tính trừu tượng (Abstraction)

Mỗi một struct trong ví dụ đại điện cho một lớp các đối tượng. Mỗi struct có một mức độ trừu tượng khác nhau. Dữ liệu trong struct là các thuộc tính của đối tượng, các con trỏ hàm trong struct là các hành vi của đối tượng.

Ở code Java, lớp DaGiac là lớp abstract, nghĩa là không thể tạo instance trực tiếp từ lớp này. Tương ứng bên C, hàm taoDaGiac của mình không cấp phát vùng nhớ.

Tính đa hình (Polymorphism)

Các biến kiểu TamGiac *, ChuNhat *, HinhVuong * mình đều có thể ép kiểu thành DaGiac * và cho chúng thực hiện hành vi của một DaGiac (tính  dienTich).

Tính kế thừa (Inheritance)

Struct tương ứng với lớp con mang theo đầy đủ các thuộc tính và hành vi của lớp cha, thông qua việc mình định nghĩa thành phần super, có kiểu dữ liệu là struct tương ứng với lớp cha và đặt ở đầu tiên.

Tính bao gói (Encapsulation)

Đối với những dữ liệu private, hay protected, chúng ta sẽ phải quy ước là không truy cập từ bên ngoài vào. Có thể quy ước theo cách đặt tên.  Ví dụ như:

Tuy nhiên, ở ví dụ đầu của bài viết này, mình chọn cách quy ước khác. Đó là dùng một thành viên có tên là private, chứa các thành viên private bên trong.

Một số vấn đề

Vấn đề cấp phát động

Mình dùng con trỏ và cấp phát động cho các đối tượng. Do đó chúng ta phải tự quản lí các vùng nhớ , tránh bị memory leak. Cấp phát động càng nhiều thì việc quản lí các vùng nhớ càng khó khăn. Một số ngôn ngữ OOP cung cấp sẵn trình dọn rác (garbage collector) để quản lí các vùng nhớ thay chúng ta. Ở ngôn ngữ C, cũng có một số trình dọn rác mã nguồn mở, nhưng mình sẽ không trình bày ở đây.

Chúng ta cũng có thể dùng cách không cấp phát động, bằng cách định nghĩa biến kiểu struct, sau đó dùng toán tử & để lấy địa chỉ gán cho con trỏ.

Vấn đề hiệu năng

Vì mình dùng con trỏ hàm để thay thế cho phương thức, do đó, nó sẽ gây lãng phí bộ nhớ. Giả sử một struct có 100 con trỏ hàm (bao gồm các các con trỏ hàm của các struct tương ứng lớp cha), struct này có 10 000 instance, thì số lượng con trỏ hàm sẽ là 1000 000. Trong khi đó, chúng ta chỉ cần 100 con trỏ hàm mà thôi, 999900 con trỏ hàm còn lại chỉ là các bản sao của 100 con trỏ hàm mà chúng ta cần.

Vậy để tối ưu cho các con trỏ hàm, chúng ta có thể tách các struct thành 2 phần. 1 phần dành cho dữ liệu, 1 phần dành cho các con trỏ hàm. Phần dành cho dữ liệu sẽ được cấp phát mỗi khi chúng ta tạo một đối tượng mới. Phần dành cho các con trỏ hàm thì được cấp phát 1 lần duy nhất (single ton).

Vấn đề ép kiểu

Upcast

Thông thường với ngôn ngữ OOP thì việc upcast sẽ được thực hiện một cách tự động. Trong khi đó, trong ví dụ, mình dùng con trỏ, muốn gọi đến phương thức của lớp cha, mình phải ép kiểu con trỏ về kiểu con trỏ của struct trương ứng lớp cha. Để xác định việc upcast có thực hiện chính xác hay không thì chúng ta phải xem định nghĩa của các struct.

Downcast

Đối với ví dụ của mình, để downcast, chúng ta phải biết chính xác con trỏ của chúng ta đang trỏ đến vùng nhớ có kiểu dữ liệu cụ thể nào (struct nào). Để biết được chính xác thì chúng ta có thể xét xem vùng nhớ đó được cấp phát ở đâu. Tuy nhiên, với tính bao gói trong OOP, thì không phải lúc nào chúng ta cũng có thể kiểm tra nó được cấp phát ở đâu.

Để giải quyết vấn đề này, chúng ta có thể quy định ID cho từng lớp. ID này, bất cứ đối tượng nào cũng phải mang theo. Để làm như vậy, chúng ta có thể định nghĩa một lớp Object, quy ước nó là lớp cha của mọi lớp khác. Sau đó chúng ta sẽ gán id của lớp cho đối tượng mỗi khi cấp phát vùng nhớ.

Ví dụ:

Tuy nhiên với ví dụ trên nếu chúng ta kiểm tra  obj->classId==CLID_PARENT thì kết quả là false. Vậy thì để kiểm tra obj có phải là instance của Parent chúng ta cần 1 cấu trúc dữ liệu khác chứa các mối quan hệ của các lớp và một hàm isInstance để thực hiện việc kiểm tra.

Gọi phương thức của super (lớp cha)

Trong ví dụ ở đầu bài viết, khi override phương thức, mình đều không dùng đến phương thức của lớp cha. Tuy nhiên trong OOP, việc này khá thường xuyên xảy ra. Áp dụng cho ngôn ngữ C trong trường hợp này, chúng ta có thể dùng 1 biến để lưu con trỏ hàm cũ (tương ứng của lớp cha) để sử dụng về sau.

Phạm vi của class

Nhiều ngôn ngữ OOP cho phép giới hạn phạm vi của class (ví dụ như phạm vi package trong Java). Đối với ngôn ngữ C, chúng ta có thể định nghĩa struct ở header (file .h) để include ở các file khác. Hoặc định nghĩa trực tiếp trong file c. Khi định nghĩa struct ở file c, ta có thể xem như nó được giới hạn ở file c đó.

Đa kế thừa

Một số ngôn ngữ OOP cho phép đa kế thừa, một số khác thì không cho phép đa kế thừa nhưng lại đưa ra khái niệm interface và cho phép 1 class implements nhiều interface. Nếu muốn mô phỏng đa kế thừa bằng ngôn ngữ C thì có lẽ sẽ như thế này:

Ở ví dụ này, mình có thể ép kiểu (A*) p. Nhưng nếu ép kiểu (B*) p thì sẽ sai, vì offset của super1 khác với super. Do đó mình lấy địa chỉ của super1 ( & p->super1) chứ không ép kiểu.

Kết

Với các vấn đề mình đã trình bày ở phần trước, mình đã cố gắng áp dụng một số giải pháp cho ví dụ ở phần đầu bài viết, bao gồm:

  • Chia code thành nhiều file
  • Gọi phương thức của lớp cha:  dienTichHinhVuong trong HinhVuong.c
  • Kiểm tra instance:  isInstance trong Object.h, Bổ sung Class.h, Class.c, thay đổi trong các hàm tạo
  • Không cấp phát động:  vuongPtr trong main.c và thay đổi trong các hàm tạo.

Các bạn có thể tham khảo source code ở đây: https://github.com/DiepEsc/coop-example

Việc áp dụng OOP cho C là khả thi. Tuy nhiên việc này sẽ tiêu tốn nhiều thời gian viết code đồng thời việc tối ưu performance, tổ chức code cũng sẽ gặp nhiều khó khăn.

Bài viết này không định hướng “áp dụng” cho thực tế, mà chỉ định hướng “áp dụng” cho vọc code mà thôi. Trong thực tế nếu bạn muốn lập trình hướng đối tượng và bạn cũng muốn dùng cú pháp của C thì các bạn hoàn toàn có thể đường đường chính chính dùng cú pháp của C trong C++ thay vì làm trò mèo như mình 😆 . Chúc các bạn vọc code vui vẻ. Mọi ý kiến đóng góp hoặc thắc mắc, vui lòng comment hoặc inbox m.me/DiepEsc.