Bài 36.1: Khái niệm và sử dụng kế thừa trong C++

Chào mừng các bạn quay trở lại với series blog về C++ của FullhouseDev! Sau khi đã làm quen với các khái niệm cơ bản về lớp (class), đóng gói (encapsulation) và trừu tượng (abstraction), hôm nay chúng ta sẽ cùng khám phá một trụ cột tiếp theo và vô cùng mạnh mẽ của Lập trình hướng đối tượng (OOP): Kế thừa (Inheritance).

Hãy tưởng tượng bạn đang xây dựng một hệ thống quản lý các loại phương tiện giao thông. Bạn có thể có các lớp riêng cho ô tô, xe máy, xe tải... Tất cả chúng đều có những đặc điểm chung như tốc độ, số bánh xe, hãng sản xuất, và các hành động chung như tăng tốc, phanh. Thay vì viết lại những đặc điểm và hành động chung này cho từng loại phương tiện, sẽ thật tuyệt nếu chúng ta có thể định nghĩa chúng ở một nơi và cho phép các loại phương tiện cụ thể thừa hưởng lại. Đó chính xác là vai trò của kế thừa!

Kế thừa trong C++ cho phép chúng ta tạo ra một lớp mới (lớp dẫn xuất - derived class) dựa trên một lớp đã có (lớp cơ sở - base class). Về cơ bản, lớp dẫn xuất sẽ thừa hưởng các thành viên (thuộc tính và phương thức) của lớp cơ sở và có thể thêm các thành viên mới hoặc tùy chỉnh lại hành vi của các thành viên được thừa hưởng. Điều này giúp ích rất lớn trong việc:

  1. Tái sử dụng mã nguồn: Không cần viết lại code cho các đặc điểm chung.
  2. Tạo ra mối quan hệ: Mô hình hóa mối quan hệ 'là một loại' (is-a relationship) trong thế giới thực (Ví dụ: Ô tô là một loại Phương tiện, Chó là một loại Động vật).
  3. Mở rộng và bảo trì: Dễ dàng thêm các loại mới hoặc thay đổi hành vi chung bằng cách chỉ sửa đổi lớp cơ sở.

1. Khái niệm cơ bản: Lớp cơ sở và Lớp dẫn xuất

  • Lớp cơ sở (Base Class): Là lớp ban đầu mà từ đó các lớp khác sẽ thừa hưởng. Nó chứa các đặc điểm và hành vi chung.
  • Lớp dẫn xuất (Derived Class): Là lớp được tạo ra bằng cách thừa hưởng từ lớp cơ sở. Nó thừa hưởng các thành viên của lớp cơ sở và có thể có các thành viên riêng của mình.

Mối quan hệ giữa lớp dẫn xuất và lớp cơ sở thường được gọi là mối quan hệ "là một loại" (is-a). Ví dụ: class Dog : public Animal nghĩa là Dog là một loại Animal.

2. Cú pháp Kế thừa

Cú pháp cơ bản để khai báo một lớp dẫn xuất như sau:

class TenLopDanXuat : CheDoTruyCap TenLopCoSo {
    // Các thành viên riêng của lớp dẫn xuất
};

Trong đó:

  • TenLopDanXuat: Tên của lớp bạn đang tạo (lớp con).
  • CheDoTruyCap: Chỉ định cách các thành viên của lớp cơ sở được truy cập trong lớp dẫn xuất và từ bên ngoài lớp dẫn xuất. Có ba chế độ chính: public, protected, và private.
  • TenLopCoSo: Tên của lớp mà bạn muốn thừa hưởng (lớp cha).

Chúng ta sẽ đi sâu vào các chế độ truy cập ngay bây giờ.

3. Các Chế độ Truy cập (Access Specifiers) trong Kế thừa

Đây là phần quan trọng để hiểu cách các thành viên của lớp cơ sở hoạt động trong lớp dẫn xuất. Chế độ truy cập khi kế thừa (public, protected, private) xác định mức độ truy cập của các thành viên thừa hưởng trong lớp dẫn xuất và đối với các đối tượng của lớp dẫn xuất.

Giả sử lớp cơ sở Base có các thành viên ở ba cấp độ truy cập: public, protected, và private.

Thành viên của Base Kế thừa public bởi Derived Kế thừa protected bởi Derived Kế thừa private bởi Derived
public member Trở thành public Trở thành protected Trở thành private
protected member Trở thành protected Trở thành protected Trở thành private
private member Không thể truy cập trực tiếp Không thể truy cập trực tiếp Không thể truy cập trực tiếp

Bây giờ, hãy xem xét ý nghĩa của từng chế độ kế thừa:

3.1. Kế thừa public

Đây là chế độ kế thừa phổ biến nhất, mô hình hóa mối quan hệ "is-a" một cách tự nhiên.

  • Các thành viên public của lớp cơ sở vẫn là public trong lớp dẫn xuất.
  • Các thành viên protected của lớp cơ sở vẫn là protected trong lớp dẫn xuất.
  • Các thành viên private của lớp cơ sở không thể được truy cập trực tiếp bởi các phương thức của lớp dẫn xuất hoặc từ bên ngoài đối tượng của lớp dẫn xuất.

Ví dụ minh họa Kế thừa public:

#include <iostream>
#include <string>

class Animal {
public:
    string name; // public member

    Animal(const string& n) : name(n) {
        cout << "Animal constructor: " << name << endl;
    }

    void eat() const { // public member function
        cout << name << " is eating." << endl;
    }

protected:
    int age; // protected member (accessible in derived classes)

    void setAge(int a) {
        age = a;
    }

private:
    string origin; // private member (not accessible in derived or outside)

    void setOrigin(const string& o) {
        origin = o; // Only accessible within Animal methods
    }

public:
    // Public method to interact with private member indirectly
    void displayOrigin(const string& o) {
        setOrigin(o); // Can call private method within public method
        cout << name << "'s origin set (via public method)." << endl;
    }

    // Public method to interact with protected member indirectly
    void displayAge(int a) {
        setAge(a); // Can call protected method within public method
        cout << name << " is " << age << " years old." << endl; // Also can access protected member directly here
    }
};

// Dog is a type of Animal -> Use public inheritance
class Dog : public Animal {
public:
    string breed; // private member of Dog

    Dog(const string& n, const string& b) : Animal(n), breed(b) { // Call base constructor
        cout << "Dog constructor: " << name << ", breed: " << breed << endl;
    }

    void bark() const { // public member function of Dog
        cout << name << " is barking!" << endl;
    }

    // Accessing base class members from derived class
    void displayDogInfo() const {
        cout << "--- Dog Info ---" << endl;
        cout << "Name: " << name << endl; // OK (public inherited)
        cout << "Breed: " << breed << endl; // OK (Dog's own member)
        // cout << "Age: " << age << endl; // OK (protected inherited, accessible within Derived)
        // cout << "Origin: " << origin << endl; // ERROR: origin is private in Animal
        // eat(); // OK (public inherited method)
        // setAge(5); // OK (protected inherited method)
        // setOrigin("Zoo"); // ERROR: setOrigin is private in Animal
        cout << "----------------" << endl;
    }
};

int main() {
    Dog myDog("Buddy", "Golden Retriever"); // Calls Animal constructor, then Dog constructor

    // Accessing members via Derived object (myDog)
    myDog.eat(); // OK: eat() is public in Animal and inherited as public in Dog
    myDog.bark(); // OK: bark() is public in Dog
    cout << "Name from main: " << myDog.name << endl; // OK: name is public in Animal and inherited as public

    myDog.displayAge(3); // Using public method in Animal, inherited by Dog
    myDog.displayOrigin("Home"); // Using public method in Animal, inherited by Dog

    // cout << myDog.age << endl; // ERROR: age is protected in Animal and remains protected in Dog
    // myDog.setAge(4); // ERROR: setAge is protected in Animal and remains protected in Dog
    // cout << myDog.origin << endl; // ERROR: origin is private in Animal
    // myDog.setOrigin("Street"); // ERROR: setOrigin is private in Animal

    cout << "\nObject going out of scope." << endl;
    // When myDog goes out of scope, Derived destructor runs first, then Base destructor (implicitly)
    return 0;
}

Giải thích code:

  • Lớp Animal có một thành viên public (name), một protected (age), và một private (origin). Nó cũng có các phương thức tương ứng.
  • Lớp Dog kế thừa public từ Animal. Điều này có nghĩa là:
    • name (từ Animal) vẫn là public trong Dog. Bạn có thể truy cập myDog.name từ main().
    • eat() (từ Animal) vẫn là public trong Dog. Bạn có thể gọi myDog.eat() từ main().
    • age (từ Animal) trở thành protected trong Dog. Bạn có thể truy cập age bên trong các phương thức của lớp Dog (như trong displayDogInfo, mặc dù đã comment), nhưng không thể truy cập trực tiếp myDog.age từ main().
    • setAge() (từ Animal) trở thành protected trong Dog. Bạn có thể gọi setAge() bên trong các phương thức của lớp Dog, nhưng không thể gọi myDog.setAge() từ main().
    • originsetOrigin() (từ Animal) vẫn là private trong Animalkhông thể truy cập trực tiếp bởi các phương thức của Dog hoặc từ main(). Tuy nhiên, các phương thức public của Animal như displayOrigin có thể được gọi từ Dog hoặc main (nếu được kế thừa là public), và chúng có thể truy cập các thành viên private của chính lớp Animal đó.
3.2. Kế thừa protected

Ít phổ biến hơn public inheritance. Khi kế thừa protected:

  • Các thành viên public của lớp cơ sở trở thành protected trong lớp dẫn xuất.
  • Các thành viên protected của lớp cơ sở vẫn là protected trong lớp dẫn xuất.
  • Các thành viên private của lớp cơ sở không thể truy cập trực tiếp.

Điều này có nghĩa là các thành viên publicprotected của lớp cơ sở sẽ chỉ có thể được truy cập bởi các phương thức của lớp dẫn xuất và các lớp kế thừa từ lớp dẫn xuất này (các lớp "cháu"), chứ không thể truy cập từ bên ngoài thông qua đối tượng của lớp dẫn xuất.

Ví dụ minh họa Kế thừa protected:

#include <iostream>

class Base {
public:
    void publicBaseMethod() {
        cout << "Base::publicBaseMethod()" << endl;
    }
protected:
    void protectedBaseMethod() {
        cout << "Base::protectedBaseMethod()" << endl;
    }
private:
    void privateBaseMethod() {
        cout << "Base::privateBaseMethod()" << endl;
    }
};

// Inherit using protected
class ProtectedDerived : protected Base {
public:
    void accessBaseMethods() {
        publicBaseMethod();    // OK: becomes protected in ProtectedDerived, accessible internally
        protectedBaseMethod(); // OK: remains protected in ProtectedDerived, accessible internally
        // privateBaseMethod(); // ERROR: still private in Base
    }
};

int main() {
    ProtectedDerived d;
    d.accessBaseMethods(); // OK: accessBaseMethods() is public in ProtectedDerived

    // d.publicBaseMethod();    // ERROR: becomes protected in ProtectedDerived, not accessible from outside
    // d.protectedBaseMethod(); // ERROR: protected, not accessible from outside
    // d.privateBaseMethod();   // ERROR: private
    return 0;
}

Giải thích code:

  • Lớp Base có các phương thức ở ba cấp độ truy cập.
  • Lớp ProtectedDerived kế thừa protected từ Base.
  • Phương thức publicBaseMethod() của Base trở thành protected trong ProtectedDerived. Do đó, nó có thể được gọi bên trong phương thức accessBaseMethods của ProtectedDerived, nhưng không thể gọi trực tiếp từ main() qua đối tượng d.
  • Phương thức protectedBaseMethod() của Base vẫn là protected trong ProtectedDerived và cũng chỉ có thể gọi bên trong ProtectedDerived.
  • Phương thức privateBaseMethod() của Base vẫn là private và không thể truy cập ở bất kỳ đâu ngoài Base.
3.3. Kế thừa private

Đây là chế độ kế thừa hạn chế nhất, thường được dùng để mô hình hóa mối quan hệ "implemented-in-terms-of" hơn là "is-a".

  • Các thành viên public của lớp cơ sở trở thành private trong lớp dẫn xuất.
  • Các thành viên protected của lớp cơ sở trở thành private trong lớp dẫn xuất.
  • Các thành viên private của lớp cơ sở không thể truy cập trực tiếp.

Khi kế thừa private, lớp dẫn xuất có thể sử dụng các thành viên publicprotected của lớp cơ sở bên trong các phương thức của nó, nhưng từ bên ngoài, đối tượng của lớp dẫn xuất không thể truy cập bất kỳ thành viên nào của lớp cơ sở (vì tất cả đều đã trở thành private trong lớp dẫn xuất).

Ví dụ minh họa Kế thừa private:

#include <iostream>

class PowerSource {
public:
    void turnOn() { cout << "PowerSource::turnOn()" << endl; }
    void turnOff() { cout << "PowerSource::turnOff()" << endl; }
protected:
    int voltage = 12;
};

// A machine might be implemented using a PowerSource, but the user doesn't
// interact with the PowerSource directly.
class Machine : private PowerSource { // Private inheritance
public:
    void startMachine() {
        cout << "Machine starting..." << endl;
        turnOn(); // OK: turnOn() is public in PowerSource, becomes private in Machine, accessible internally
        cout << "Voltage used: " << voltage << endl; // OK: voltage is protected in PowerSource, becomes private in Machine, accessible internally
    }

    void stopMachine() {
        cout << "Machine stopping..." << endl;
        turnOff(); // OK: turnOff() is public in PowerSource, becomes private in Machine, accessible internally
    }
};

int main() {
    Machine m;
    m.startMachine(); // OK: public method of Machine
    m.stopMachine();  // OK: public method of Machine

    // m.turnOn();  // ERROR: turnOn() is private in Machine because of private inheritance
    // m.turnOff(); // ERROR: turnOff() is private in Machine because of private inheritance
    // cout << m.voltage << endl; // ERROR: voltage is private in Machine
    return 0;
}

Giải thích code:

  • Lớp PowerSource có các thành viên publicprotected.
  • Lớp Machine kế thừa private từ PowerSource.
  • Các phương thức turnOn()turnOff() (là public trong PowerSource) trở thành private trong Machine. Chúng có thể được gọi bên trong các phương thức của Machine (startMachine, stopMachine), nhưng không thể gọi từ main() qua đối tượng m.
  • Thành viên voltage (là protected trong PowerSource) trở thành private trong Machine và chỉ có thể truy cập bên trong Machine.

Tóm lại về Chế độ Truy cập:

  • Sử dụng public inheritance khi lớp dẫn xuất là một loại của lớp cơ sở (mối quan hệ "is-a").
  • Sử dụng private inheritance khi lớp dẫn xuất được triển khai dựa trên lớp cơ sở, nhưng mối quan hệ này không cần được phơi bày ra bên ngoài (mối quan hệ "implemented-in-terms-of" hoặc "uses-a").
  • protected inheritance ít phổ biến hơn và thường được sử dụng trong các cấu trúc kế thừa sâu, nơi bạn muốn cho phép các lớp "cháu" truy cập các thành viên của lớp "ông cha", nhưng không cho phép truy cập từ bên ngoài hệ thống kế thừa đó.

4. Constructor và Destructor trong Kế thừa

Constructor và destructor của lớp cơ sở không được thừa hưởng bởi lớp dẫn xuất. Tuy nhiên, chúng được gọi theo một trình tự cụ thể khi đối tượng được tạo và hủy.

  • Thứ tự gọi Constructor: Khi tạo một đối tượng của lớp dẫn xuất, constructor của lớp cơ sở luôn được gọi trước constructor của lớp dẫn xuất. Điều này là hợp lý vì lớp cơ sở cần được khởi tạo hoàn chỉnh trước khi lớp dẫn xuất có thể sử dụng các thành viên của nó.
  • Thứ tự gọi Destructor: Khi hủy một đối tượng của lớp dẫn xuất, destructor của lớp dẫn xuất luôn được gọi trước destructor của lớp cơ sở.

Bạn có thể gọi constructor cụ thể của lớp cơ sở từ danh sách khởi tạo (initializer list) của constructor lớp dẫn xuất. Nếu bạn không gọi constructor nào của lớp cơ sở, constructor mặc định (constructor không tham số) của lớp cơ sở sẽ được gọi một cách ngầm định.

Ví dụ minh họa Constructor và Destructor:

#include <iostream>
#include <string>

class Base {
public:
    Base() {
        cout << "Base default constructor called." << endl;
    }
    Base(const string& msg) {
        cout << "Base constructor called with message: " << msg << endl;
    }
    ~Base() {
        cout << "Base destructor called." << endl;
    }
};

class Derived : public Base {
public:
    // Derived default constructor (implicitly calls Base default constructor)
    Derived() {
        cout << "Derived default constructor called." << endl;
    }

    // Derived constructor calling a specific Base constructor
    Derived(const string& msg) : Base(msg) { // Call Base(msg) constructor
        cout << "Derived constructor called with message: " << msg << endl;
    }

    ~Derived() {
        cout << "Derived destructor called." << endl;
    }
};

int main() {
    cout << "--- Creating obj1 (using default constructor) ---" << endl;
    Derived obj1; // Calls Base(), then Derived()

    cout << "\n--- Creating obj2 (using specific constructor) ---" << endl;
    Derived obj2("Hello from Derived!"); // Calls Base("Hello..."), then Derived()

    cout << "\n--- obj1 and obj2 going out of scope ---" << endl;
    // obj2 goes out of scope first, then obj1
    // Order for each: Derived destructor, then Base destructor
    return 0;
}

Giải thích code:

  • Khi obj1 được tạo, nó gọi constructor mặc định của Derived. Vì constructor mặc định của Derived không gọi constructor nào của Base trong danh sách khởi tạo, constructor mặc định của Base (Base()) được gọi một cách ngầm định trước.
  • Khi obj2 được tạo, constructor của Derived với tham số string được gọi. Trong danh sách khởi tạo : Base(msg), chúng ta gọi rõ ràng constructor của Base với tham số msg. Do đó, Base("Hello from Derived!") được gọi trước, sau đó mới đến thân của constructor Derived.
  • Khi chương trình kết thúc, các đối tượng cục bộ obj2obj1 lần lượt bị hủy. Đối với mỗi đối tượng, destructor của lớp dẫn xuất (~Derived()) được gọi trước, sau đó là destructor của lớp cơ sở (~Base()).

5. Truy cập Thành viên Lớp Cơ sở từ Lớp Dẫn xuất

Như đã thấy trong các ví dụ về chế độ truy cập, lớp dẫn xuất có thể truy cập các thành viên publicprotected của lớp cơ sở trực tiếp bằng tên của chúng (như thể chúng là thành viên của chính lớp dẫn xuất).

#include <iostream>

class Base {
protected: // Accessible in derived classes
    int baseProtectedData;
public:
    Base() : baseProtectedData(100) {}
    void basePublicMethod() const {
        cout << "Base public method. Data: " << baseProtectedData << endl;
    }
};

class Derived : public Base {
public:
    void accessBaseMembers() const {
        // Accessing protected member from Base (OK)
        cout << "Accessing baseProtectedData from Derived: " << baseProtectedData << endl;

        // Calling public method from Base (OK)
        basePublicMethod();
    }
};

int main() {
    Derived d;
    d.accessBaseMembers();
    // d.baseProtectedData; // ERROR: baseProtectedData is protected in Derived
    // d.basePublicMethod(); // OK: basePublicMethod is public in Base and remains public in Derived
    return 0;
}

Giải thích code:

  • Trong phương thức accessBaseMembers của lớp Derived, chúng ta có thể truy cập trực tiếp baseProtectedData (thành viên protected của Base) và gọi basePublicMethod() (thành viên public của Base).
  • Tuy nhiên, từ main(), chúng ta không thể truy cập baseProtectedData vì nó vẫn là protected trong Derived. basePublicMethod() thì có thể gọi vì nó được kế thừa là public.

6. Đa Kế thừa (Multiple Inheritance)

C++ cũng hỗ trợ Đa Kế thừa (Multiple Inheritance), nơi một lớp dẫn xuất có thể kế thừa từ nhiều lớp cơ sở. Cú pháp chỉ đơn giản là liệt kê các lớp cơ sở được phân tách bằng dấu phẩy:

class Derived : CheDoTruyCap1 Base1, CheDoTruyCap2 Base2 {
    // ...
};

Đa kế thừa mạnh mẽ nhưng cũng có thể dẫn đến các vấn đề phức tạp như "diamond problem" (vấn đề kim cương) khi một lớp kế thừa từ hai lớp cùng kế thừa từ một lớp chung. Chúng ta sẽ tìm hiểu sâu hơn về đa kế thừa và cách xử lý các vấn đề tiềm ẩn của nó trong các bài viết sau. Hiện tại, hãy tập trung vào kế thừa đơn giản (kế thừa từ một lớp cơ sở).

Comments

There are no comments at the moment.