Menu

Kế thừa trong C++: lớp cơ sở và lớp dẫn xuất

Tìm hiểu cách kế thừa trong C++ cho phép lớp dẫn xuất dùng lại và mở rộng lớp cơ sở: cú pháp, kế thừa public và private, thứ tự gọi hàm khởi tạo và hàm hủy, cùng các bẫy như object slicing.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Dùng lại một lớp bằng cách mở rộng nó

Bạn đã xây dựng các lớp với hàm khởi tạo và dọn dẹp chúng bằng hàm hủy. Kế thừa (inheritance) là bước tiếp theo: thay vì chép các thành viên của lớp này sang lớp khác, bạn khai báo rằng một lớp mới là phiên bản chuyên biệt của một lớp có sẵn và để nó tự động kế thừa mọi thứ.

Lớp có sẵn là lớp cơ sở (base class, hay lớp cha); lớp mới là lớp dẫn xuất (derived class, hay lớp con). Lớp dẫn xuất bắt đầu với toàn bộ dữ liệu và hành vi của lớp cơ sở, rồi thêm hoặc thay đổi những gì làm nó khác biệt. Đây là công cụ chính của C++ cho quan hệ "is-a": một Dog là một Animal, một SavingsAccount là một BankAccount.

Cú pháp cơ bản: class Derived : public Base

Để kế thừa, bạn viết sau tên lớp dẫn xuất một dấu hai chấm, một access specifier và tên lớp cơ sở. Dạng phổ biến nhất là kế thừa public.

Dog không hề khai báo name hay eat(), vậy mà cả hai vẫn dùng được trên đối tượng Dog vì chúng được kế thừa từ Animal. Lớp dẫn xuất được tự do thêm các thành viên như bark() mà lớp cơ sở không hề biết đến.

protected: thành viên chỉ dành cho lớp con

Thành viên private của lớp cơ sở không truy cập được bên trong lớp dẫn xuất: kế thừa không phá vỡ tính đóng gói. Khi bạn muốn giấu phần bên trong của lớp cơ sở khỏi thế giới bên ngoài nhưng vẫn cho lớp con dùng, hãy dùng access specifier protected.

Hãy hình dung ba mức như những vòng tròn đồng tâm: private là "chỉ lớp này", protected là "lớp này và các lớp con cháu", còn public là "tất cả mọi người". Xem access-specifiers để nắm toàn cảnh.

Thứ tự hàm khởi tạo và hàm hủy

Một đối tượng dẫn xuất chứa một đối tượng con thuộc lớp cơ sở, và phần cơ sở này phải tồn tại trước khi phần dẫn xuất được xây dựng. Vì vậy việc khởi tạo chạy từ lớp cơ sở trước, việc hủy chạy từ lớp dẫn xuất trước (đúng thứ tự ngược lại). Nếu lớp cơ sở cần đối số cho hàm khởi tạo, bạn truyền chúng qua danh sách khởi tạo thành viên (member-initializer list).

Đầu ra cho thấy rõ thứ tự này:

Animal ctor: Rex   // base built first
Dog ctor           // then the derived part
Dog dtor           // destroyed in reverse...
Animal dtor: Rex   // ...base last

Nếu bạn quên : Animal(n) và lớp cơ sở không có hàm khởi tạo mặc định, code sẽ không biên dịch được, vì C++ không biết phải tạo phần cơ sở như thế nào. Một lớp cơ sở mà bạn định cho kế thừa gần như luôn nên khai báo hàm hủy (và, như trang tiếp theo sẽ chỉ ra, thường là hàm hủy virtual).

Ghi đè: định nghĩa lại phương thức của lớp cơ sở

Lớp dẫn xuất có thể thay thế một phương thức được kế thừa bằng cách khai báo một phương thức có cùng signature. Bạn vẫn gọi được bản gốc qua Base::method().

Đây chỉ là che tên (name hiding) thông thường, chưa phải đa hình: describe() nào được chạy là do kiểu tĩnh của biến quyết định lúc biên dịch. Đó là một giới hạn quan trọng: nếu bạn gọi qua một Shape& hay Shape* thật ra đang trỏ tới một Circle, bạn vẫn nhận được Shape::describe(). Muốn sửa điều đó cần đến virtual, chủ đề của trang tiếp theo.

Cẩn thận với object slicing

Vì một tham chiếu hay con trỏ kiểu cơ sở có thể trỏ đến đối tượng dẫn xuất, bạn dễ bị cám dỗ sao chép một đối tượng dẫn xuất vào một biến kiểu cơ sở. Đừng làm vậy: phần dẫn xuất sẽ bị cắt mất.

a là một Animal thật sự, không phải một Dog mang nhãn lớp cơ sở, nên breed đơn giản là không tồn tại trên nó. Để làm việc đa hình với các đối tượng dẫn xuất, bạn phải dùng tham chiếu (Animal&) hoặc con trỏ (Animal*) kiểu cơ sở, không bao giờ dùng giá trị kiểu cơ sở. Slicing diễn ra trong im lặng: code biên dịch trơn tru và chỉ âm thầm làm mất dữ liệu, nên đây là một trong những bug kế thừa dễ lọt ra sản phẩm nhất.

Những lỗi cần tránh

  • Tưởng rằng lớp con truy cập được thành viên private của lớp cơ sở. Không được. Dùng protected cho dữ liệu mà lớp dẫn xuất thực sự cần, và giữ trạng thái nội bộ thật sự ở private.
  • Quên truyền đối số cho hàm khởi tạo của lớp cơ sở. Nếu lớp cơ sở không có hàm khởi tạo mặc định, bạn phải gọi nó tường minh trong danh sách khởi tạo của hàm khởi tạo lớp dẫn xuất (: Base(args)).
  • Cắt đối tượng dẫn xuất thành một giá trị kiểu cơ sở. Sao chép một Dog vào một Animal làm mất mọi thứ riêng của Dog. Thay vào đó, hãy truyền và lưu tham chiếu hoặc con trỏ kiểu cơ sở.
  • Lạm dụng kế thừa để dùng lại code. Kế thừa mô tả quan hệ "is-a". Nếu quan hệ thực chất là "has-a" (một Car có một Engine), hãy ưu tiên composition, tức một đối tượng thành viên, thay vì kế thừa.

Tiếp theo: hàm ảo

Việc ghi đè bạn vừa thấy được quyết định lúc biên dịch, nên gọi qua con trỏ lớp cơ sở sẽ bỏ qua phiên bản của lớp dẫn xuất. Trang tiếp theo, virtual-functions, giới thiệu từ khóa virtual và override: cơ chế để kiểu lúc chạy quyết định phương thức nào được gọi, mở ra đa hình thực sự và giải thích vì sao lớp cơ sở cần hàm hủy virtual.

Câu hỏi thường gặp

Kế thừa trong C++ là gì?

Kế thừa cho phép bạn định nghĩa một lớp mới (lớp dẫn xuất) dựa trên một lớp có sẵn (lớp cơ sở). Lớp dẫn xuất tự động có các thành viên dữ liệu và hàm thành viên của lớp cơ sở, đồng thời có thể thêm mới hoặc thay thế hành vi có sẵn. Kế thừa mô tả quan hệ "is-a" (một Dog là một Animal) và là cách chính để C++ dùng lại và mở rộng code trong một hệ thống phân cấp lớp.

Kế thừa public và private trong C++ khác nhau thế nào?

Với kế thừa public (class Dog : public Animal), giao diện public của lớp cơ sở vẫn là public trong lớp dẫn xuất, nên một Dog là một Animal và có thể dùng ở bất cứ đâu cần Animal. Với kế thừa private, các thành viên được kế thừa trở thành private: lớp dẫn xuất dùng lại phần cài đặt của lớp cơ sở nhưng không thay thế được cho nó. Kế thừa public là trường hợp phổ biến hơn hẳn; chỉ dùng private khi muốn dùng lại theo kiểu "implemented-in-terms-of".

Khi kế thừa trong C++, hàm khởi tạo và hàm hủy chạy theo thứ tự nào?

Hàm khởi tạo chạy từ lớp cơ sở trước: lớp cơ sở được tạo hoàn chỉnh trước khi thân hàm khởi tạo của lớp dẫn xuất chạy. Hàm hủy chạy theo đúng thứ tự ngược lại: lớp dẫn xuất trước, rồi đến lớp cơ sở. Điều này bảo đảm rằng trong lúc một đối tượng dẫn xuất đang được tạo hoặc hủy, mọi phần mà nó phụ thuộc đều đã tồn tại (hoặc vẫn còn tồn tại).

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu