Trong hai bài học trước, chúng ta đã đề cập đến một số kiến thức cơ bản xoay quanh tính kế thừa trong C++ và thứ tự khởi tạo của các subclass (lớp dẫn xuất, lớp con). Trong bài này, chúng ta sẽ xem xét kỹ hơn về vai trò của các hàm constructor trong việc khởi tạo các subclass. Để làm vậy, chúng ta sẽ tiếp tục sử dụng class Base và class Derived từ bài trước để làm ví dụ minh họa:

class Base
{
public:
    int m_id;
 
    Base(int id=0)
        : m_id{ id }
    {
    }
 
    int getId() const { return m_id; }
};
 
class Derived: public Base
{
public:
    double m_cost;
 
    Derived(double cost=0.0)
        : m_cost{ cost }
    {
    }
 
    double getCost() const { return m_cost; }
};

Đối với các non-derived class (các lớp không kế thừa từ lớp nào), các hàm constructors ở các class đó chỉ phải quan tâm tới các thành viên (biến thành viên và hàm thành viên) nội bộ của riêng các non-derived(non-subclass) class đó.

int main()
{
    Base base{ 5 }; // use Base(int) constructor
 
    return 0;
}

Dưới đây là những gì thực sự diễn ra khi đối tượng derived được thể hiện:

1. Cấp phát bộ nhớ cho đối tượng derived (dung lượng đủ cho cả phần Base và phần Derived)

2. Gọi đến hàm constructor thích hợp của class Derived

3. Đối tượng Base sẽ được xây dựng trước bằng cách sử dụng hàm constructor thích hợp của class Base. Nếu người dùng không cung cấp hàm constructor nào, thì hàm constructor mặc định của class Base sẽ được sử dụng

4. Danh sách khởi tạo (initialization list) sẽ khởi tạo các biến

5. Phần thân hàm của hàm constructor trong class Base được thực thi

6. Điều khiển được trả về cho đối tượng gọi hàm – caller (chính là hàm constructor thích hợp của class Derived).

Sự khác biệt thực sự duy nhất giữa trường hợp này và trường hợp không kế thừa đó là trước khi hàm constructor của class Derived có thể làm được bất kể điều gì đáng kể, thì hàm constructor của class Base sẽ được gọi đến trước. Hàm constructor của class Base sẽ thiết lập phần Base của đối tượng derived, điều khiển sau đó sẽ được trả lại cho hàm constructor của class Derived, và hàm constructor của class Derived sẽ được phép hoàn thành công việc của nó.

1. Khởi tạo các thành viên của class Base

Một trong những thiếu sót của class Derived hiện tại là không có cách nào để khởi tạo biến m_id khi chúng ta tạo ra một đối tượng Derived. Sẽ thế nào nếu chúng ta muốn gán giá trị cho cả biến m_cost (từ phần Derived của đối tượng derived) và biến m_id (từ phần Base của đối tượng derived) khi tạo ra một đối tượng Derived?

Các lập trình viên mới thường cố gắng giải quyết vấn đề này như sau:

class Derived: public Base
{
public:
    double m_cost;
 
    Derived(double cost=0.0, int id=0)
        // does not work
        : m_cost{ cost }, m_id{ id }
    {
    }
 
    double getCost() const { return m_cost; }
};

Đây là một nỗ lực tốt, và gần như là ý tưởng đúng. Chúng ta chắc chắn cần khai báo thêm một tham số khác cho hàm constructor, nếu không C++ sẽ không cách nào biết được chúng ta muốn khởi tạo giá trị nào cho biến m_id.

Tuy nhiên C++ sẽ ngăn cản, không cho phép các class khởi tạo các biến thành viên được kế thừa từ base class – lớp cha, ở bên trong initialization list (trong ví dụ này, 2 câu lệnh m_cost{ cost }, m_id{ id } chính là các initializers nằm trong 1 initialization list) của hàm constructor thuộc derived class – lớp con hiện tại. Nói cách khác, giá trị của một biến chỉ có thể được gán ở bên trong initialization list của một hàm constructor, mà hàm constructor này phải thuộc về cùng một class với biến muốn gán/khởi tạo giá trị.
Tại sao C++ lại ngăn trở việc khởi tạo/gán giá trị cho các biến thành viên được kế thừa từ base class, ở bên trong initialization list của hàm constructor thuộc class con/class dẫn xuất hiện tại? Để trả lời được câu hỏi này, chúng ta phải nhắc lại một chút về các biến hằng (const variables) và biến tham chiếu (reference variables). Bạn thử nghĩ xem điều gì sẽ xảy ra nếu biến m_id là hằng. Bởi vì các biến hằng nhất định phải được khởi tạo với một giá trị tại thời điểm chúng được tạo ra, nên hàm constructor của base class – lớp cha phải gán giá trị cho biến hằng khi biến này được tạo ra. Tuy nhiên, khi hàm constructor của base class được thực thi xong, thì initialization list của các hàm constructors của derived class – lớp dẫn xuất/lớp con sẽ được thực thi. Khi ấy, mỗi derived class – lớp con sẽ có được cơ hội để có thể khởi tạo biến hằng thuộc base class đó (thêm một lần nữa), dẫn đến khả năng thay đổi giá trị của biến hằng đó! Do đó, bằng cách hạn chế chỉ cho phép khởi tạo các biến bên trong những hàm constructor mà thuộc cùng một class với các biến đó, C++ sẽ đảm bảo được rằng tất cả các biến đều chỉ được khởi tạo một lần, khi đối tượng mới được tạo ra.

Kết quả cuối cùng là đoạn code ví dụ gần nhất ở trên sẽ không hoạt động vì biến m_id được kế thừa từ Base, trong khi chỉ các biến không kế thừa (non-inherited variables) mới có thể được khởi tạo bên trong initialization list của class Derived.
Tuy nhiên, giá trị của các biến kế thừa vẫn có thể được thay đổi bên trong phần thân hàm của constructor, bằng cách sử dụng một phép gán. Do đó, các lập trình viên mới cũng thường thử cách này:

class Derived: public Base
{
public:
    double m_cost;
 
    Derived(double cost=0.0, int id=0)
        : m_cost{ cost }
    {
        m_id = id;
    }
 
    double getCost() const { return m_cost; }
};

Mặc dù đoạn code trên sẽ thực sự hoạt động trong trường hợp này, nhưng nó sẽ không hoạt động nếu biến m_id là biến hằng hoặc biến tham chiếu (bởi vì các giá trị hằng và tham chiếu phải được khởi tạo bên trong initialization list của hàm constructor). Cách này cũng không hiệu quả bởi vì biến m_id sẽ được gán hai lần cùng một giá trị: Một lần bên trong initialization list của hàm constructor của Base class, và sau đó thêm một lần nữa bên trong phần thân hàm của constructor của class Derived. Và cuối cùng, sẽ thế nào nếu class Base cần truy cập tới giá trị biến m_id trong suốt quá trình xây dựng đối tượng? Nó sẽ không cách nào truy cập được vào giá trị của biến m_id, bởi vì giá trị của biến này sẽ vẫn không được gán cho đến khi hàm constructor của class Derived được thực thi xong (điều này gần như sẽ diễn ra sau cùng).

Vậy thì làm thế nào để khởi tạo đúng được biến m_id khi tạo ra một đối tượng của class Derived?

Trong tất cả các ví dụ cho đến nay, khi chúng ta thể hiện một đối tượng của class Derived, thì phần class Base đều được tạo bằng cách sử dụng hàm constructor mặc định của class Base. Tại sao nó luôn sử dụng hàm constructor mặc định? Bởi vì chúng ta chưa từng bảo nó phải làm khác!

May mắn thay, C++ thật sự có cung cấp cho chúng ta khả năng chọn rõ ràng hàm constructor nào của class Base sẽ được gọi! Để thực hiện việc này, chỉ cần thêm một lời gọi tới hàm constructur mong muốn của class Base, ở bên trong phần initialization list của hàm constructor của class Derived:

class Derived: public Base
{
public:
    double m_cost;
 
    Derived(double cost=0.0, int id=0)
        : Base{ id }, // Call Base(int) constructor with value id!
            m_cost{ cost }
    {
    }
 
    double getCost() const { return m_cost; }
};

Giờ đây, khi chúng ta thực thi đoạn code này:

int main()
{
    Derived derived{ 1.3, 5 }; // use Derived(double, int) constructor
    std::cout << "Id: " << derived.getId() << '\n';
    std::cout << "Cost: " << derived.getCost() << '\n';
 
    return 0;
}

Hàm constructor của class Base Base(int) sẽ được sử dụng để khởi tạo giá trị 5 cho biến m_id, và hàm constructor của class Derived sẽ được sử dụng để khởi tạo giá trị 1.3 cho biến m_cost!

Do đó, đoạn chương trình trên sẽ in ra:

Id: 5
Cost: 1.3

Cụ thể hơn, dưới đây là những gì đã xảy ra:

1. Bộ nhớ dành cho đối tượng derived được cấp phát

2. Hàm constructor Derived(double, int) được gọi, trong đó 2 tham số hàm được truyền cho giá trị là cost = 1.3 và id = 5

3. Trình biên dịch sẽ kiểm tra xem liệu rằng chúng ta có yêu cầu gọi tới một hàm constructor cụ thể nào đó của class Base hay không. Chúng ta có! Vì vậy, nó sẽ gọi tới hàm constructor Base(int) với giá trị tham số hàm được truyền cho là id = 5

4. Phần initialization list của hàm constructor của class Base sẽ gán cho biến m_id giá trị 5

5. Phần thân hàm của hàm constructor của class Base được thực thi, trong đó sẽ không có điều gì diễn ra, bởi vì phần thân hàm này trống

6. Hàm constructor của class Base sẽ return (kết thúc hàm)

7. Phần ininitialization list của hàm constructor của class Derived sẽ gán cho biến m_cost giá trị 1.3

8. Phần thân hàm constructor của class Derived được thực thi, không diễn ra điều gì, bởi vì phần thân hàm này trống.

9. Hàm constructor của class Derived sẽ return (kết thúc hàm).
Các bước thực thi ở trên có vẻ hơi phức tạp, nhưng thực sự nó rất đơn giản. Tất cả những gì xảy ra chỉ là hàm constructor của class Derived đang gọi tới một hàm constructor cụ thể của class Base, để khởi tạo phần Base (Base portion) của đối tượng derived. Bởi vì m_id nằm trong phần Base của đối tượng derived, nên hàm constructor của class Base là hàm constructor duy nhất có thể khởi tạo giá trị cho biến m_id.

Lưu ý rằng, cho dù gọi đến hàm constructor của class Base ở bất cứ chỗ nào bên trong phần initialization list của class Derived , thì hàm constructor của class Base sẽ vẫn luôn luôn được thực thi trước.

2. Bây giờ chúng ta đã có thể thiết lập private cho các thành viên của class

 Đến thời điểm hiện tại, bạn đã biết cách khởi tạo các thành viên của base class – lớp cha, nên chúng ta không cần phải đặt chế độ kiểm soát truy cập public cho các biến thành viên nữa. Chúng ta sẽ đặt lại private cho các biến thành viên, bởi vì chúng nên được thiết lập như vậy.

Nhắc lại cho bạn một chút, các biến thành viên public có thể được truy cập tới bởi bất kỳ ai. Còn các biến thành viên private chỉ có thể được truy cập tới bởi các hàm thành viên nằm trong cùng class. Lưu ý rằng, điều này có nghĩa là các derived classes (lớp dẫn xuất, lớp con) sẽ không thể truy cập trực tiếp đến các thành viên private (gồm biến thành viên và hàm thành viên) của base class (lớp cha)! Các derived classes sẽ cần phải sử dụng các hàm truy cập để truy cập tới các thành viên private của base class.

Xét ví dụ sau:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
 
class Base
{
private: // our member is now private
    int m_id;
 
public:
    Base(int id=0)
        : m_id{ id }
    {
    }
 
    int getId() const { return m_id; }
};
 
class Derived: public Base
{
private: // our member is now private
    double m_cost;
 
public:
    Derived(double cost=0.0, int id=0)
        : Base{ id }, // Call Base(int) constructor with value id!
            m_cost{ cost }
    {
    }
 
    double getCost() const { return m_cost; }
};
 
int main()
{
    Derived derived{ 1.3, 5 }; // use Derived(double, int) constructor
    std::cout << "Id: " << derived.getId() << '\n';
    std::cout << "Cost: " << derived.getCost() << '\n';
 
    return 0;
}

Trong đoạn code trên, chúng ta đã đặt biến m_id và biến m_cost thành private. Điều này hoàn toản ổn, bởi vì chúng ta đã sử dụng các hàm constructors thích hợp, có liên quan để khởi tạo chúng, và sử dụng một hàm truy cập được thiết lập public để lấy về các giá trị này.

Kết quả được in ra, đúng như mong đợi:

Id: 5
Cost: 1.3

Chúng ta sẽ nói nhiều hơn về các mức độ kiểm soát truy cập trong bài tiếp theo.

3. Một ví dụ khác

Chúng ta hãy cùng xem lại một cặp class khác mà chúng ta đã từng sử dụng làm ví dụ trước đây:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <string>
 
class Person
{
public:
    std::string m_name;
    int m_age;
 
    Person(const std::string& name = "", int age = 0)
        : m_name{ name }, m_age{ age }
    {
    }
 
    const std::string& getName() const { return m_name; }
    int getAge() const { return m_age; }
};
 
// BaseballPlayer publicly inheriting Person
class BaseballPlayer : public Person
{
public:
    double m_battingAverage;
    int m_homeRuns;
 
    BaseballPlayer(double battingAverage = 0.0, int homeRuns = 0)
       : m_battingAverage{ battingAverage },
         m_homeRuns{ homeRuns }
    {
    }
};

Như chúng ta đã từng viết chúng trước đó, class BaseballPlayer chỉ khởi tạo các thành viên của riêng nó, và không chỉ định sử dụng hàm constructor nào của class Person. Điều này có nghĩa là mọi đối tượng BaseballPlayer mà chúng ta tạo ra sẽ sử dụng hàm constructor mặc định của class Person, trong đó, hàm constructor mặc định này sẽ khởi tạo biến name là chuỗi trống không ký tự, và biến age thành 0. Bởi vì việc cung cấp name và age cho mỗi đối tượng BaseballPlayer được tạo ra là hợp lý, nên chúng ta sẽ chỉnh sửa hàm constructor này để bổ sung thêm các tham số truyền vào đó.

Dưới đây là các class đã được cập nhật của chúng ta, chúng đều sử dụng các thành viên private, trong đó, class BaseballPlayer đang gọi đến hàm constructor thích hợp của class Person để khởi tạo các biến thành viên kế thừa được từ class Person:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
#include <string>
 
class Person
{
private:
    std::string m_name;
    int m_age;
 
public:
    Person(const std::string& name = "", int age = 0)
        : m_name{ name }, m_age{ age }
    {
    }
 
    const std::string& getName() const { return m_name; }
    int getAge() const { return m_age; }
 
};
// BaseballPlayer publicly inheriting Person
class BaseballPlayer : public Person
{
private:
    double m_battingAverage;
    int m_homeRuns;
 
public:
    BaseballPlayer(const std::string& name = "", int age = 0,
        double battingAverage = 0.0, int homeRuns = 0)
        : Person{ name, age }, // call Person(const std::string&, int) to initialize these fields
            m_battingAverage{ battingAverage }, m_homeRuns{ homeRuns }
    {
    }
 
    double getBattingAverage() const { return m_battingAverage; }
    int getHomeRuns() const { return m_homeRuns; }
};

Bây giờ, chúng ta có thể tạo ra các đối tượng BaseballPlayer:

int main()
{
    BaseballPlayer pedro{ "Pedro Cerrano", 32, 0.342, 42 };
 
    std::cout << pedro.getName() << '\n';
    std::cout << pedro.getAge() << '\n';
    std::cout << pedro.getHomeRuns() << '\n';
 
    return 0;
}

Kết quả in ra là:

Pedro Cerrano
32
42

Như bạn thấy đó, biến name và age từ base class – lớp cha Person đã được khởi tạo đúng, số lượng các cú home runs được lấy từ derived class – lớp con cũng chính xác.

4. Chuỗi kế thừa

Các classes nằm bên trong một chuỗi kế thừa cũng hoạt động theo cách giống như đã đề cập ở các phần trên.

#include <iostream>
 
class A
{
public:
    A(int a)
    {
        std::cout << "A: " << a << '\n';
    }
};
 
class B: public A
{
public:
    B(int a, double b)
    : A{ a }
    {
        std::cout << "B: " << b << '\n';
    }
};
 
class C: public B
{
public:
    C(int a , double b , char c)
    : B{ a, b }
    {
        std::cout << "C: " << c << '\n';
    }
};
 
int main()
{
    C c{ 5, 4.3, 'R' };
 
    return 0;
}

Trong ví dụ này, class C được dẫn xuất từ class B, và class B lại được dẫn xuất từ class A. Vậy thì điều gì sẽ xảy ra khi chúng ta thể hiện một đối tượng thuộc class C?
Đầu tiên, hàm main() sẽ gọi tới hàm C(int, double, char). Rồi hàm constructor của class C sẽ gọi đến hàm constructor của class B B(int, double). Sau đó hàm constructor của class B sẽ gọi đến hàm constructor của class A A(int). Bởi vì class A không kế thừa từ bất cứ class nào, nên đây sẽ là class thứ nhất mà chúng ta sẽ xây dựng. Sau khi class A được xây dựng xong, giá trị 6 sẽ được in ra, và điều khiển được trả về cho class B. Lớp B được xây dựng xong, sẽ in ra kết quả 4.3, và trả điều khiển về cho class C. Sau khi class C đã được xây dựng xong, giá trị ‘R’ sẽ được in ra, và điều khiển được trả về cho hàm main(). Đến đây là thực thi xong.

Do đó, đoạn chương trình trên sẽ in ra:

A: 5
B: 4.3
C: R

Điều đáng nói là các hàm constructor chỉ có thể gọi đến hàm constructor của lớp cha trực tiếp của chúng. Do đó, hàm constructor của class C không thể gọi hoặc truyền tham số trực tiếp cho hàm constructor của class A. Hàm constructor của class C chỉ có thể gọi đến hàm constructor của class B (hàm constructor của class B lại có tránh nhiệm gọi tới hàm constructor của class A).

5. Các hàm destructors – hàm hủy

Khi Một derived class (lớp dẫn xuất, lớp con) được hủy đi, các hàm destructor sẽ được gọi theo thứ tự ngược với thứ tự gọi hàm constructor. Trong ví dụ trên, khi đối tượng c được hủy, hàm destructor của class C sẽ được gọi đầu tiên, sau đó là hàm destructor của class B, cuối cùng là hàm destructor của class A.

Bài tập thực hành về tính kế thừa trong C++

Bài tập thực hành cơ bản về lớp(phần 1) trong C++

Bài tập thực hành cơ bản về lớp(phần 2) trong C++

Bài tập thực hành cơ bản về lớp(phần 3) trong C++

Bài tập thực hành cơ bản về lớp(phần 4) trong C++

6. Tổng kết

Khi xây dựng một derived class (lớp dẫn xuất, lớp con), thì hàm constructor của derived class(subclass) phải có trách nhiệm xác định nên gọi tới hàm constructor nào của base class (lớp cha). Nếu không có hàm constructor của base class nào được chỉ định, hàm constructor mặc định của base class sẽ được sử dụng. Trong trường hợp đó, nếu không thể tìm thấy được hàm constructor mặc định nào của base class (hoặc không có hàm constructor mặc định nào được tạo), trình biên dịch sẽ hiển thị thông báo lỗi. Các class sau đó sẽ được xây dựng theo thứ tự từ lớp cha cao nhất cho đến lớp con thấp nhất.
Đến thời điểm này, bạn đã hiểu đủ về tính kế thừa trong C++ để có thể tự tạo ra được các lớp kế thừa của riêng mình!

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!