1. Các hàm constructor có chức năng trùng nhau(Overlapping)

Khi bạn khởi tạo một đối tượng mới, hàm constructor của đối tượng này sẽ được gọi ngầm bởi trình biên dịch của ngôn ngữ C++. Việc nhìn thấy nhiều hàm constructor có chức năng trùng nhau bên trong một class không phải là hiếm, Xét class trong ví dụ sau:

class Foo
{
public:
    Foo()
    {
        // code to do A
    }
 
    Foo(int value)
    {
        // code to do A
        // code to do B
    }
};

Class này có hai hàm constructors: Một hàm constructor mặc định, và một hàm constructor nhận vào một tham số kiểu integer. Bởi vì phần mã nguồn “code to do A” được yêu cầu phải nằm trong cả hai hàm constructors, nên phần mã nguồn này đã được sao chép cho cả hai hàm constructors.

Bạn biết đấy, việc tồn tại những đoạn code trùng lặp bên trong chương trình là một điều cần phải tránh càng nhiều càng tốt, vì vậy, chúng ta sẽ cùng tìm hiểu một số cách để giải quyết vấn đề này.

2. Giải pháp minh bạch không hoạt động trước khi C++ 11 ra đời

Giải pháp minh bạch tức là yêu cầu hàm constructor Foo(int) gọi đến hàm constuctor Foo(), để thực thi phần code A.

class Foo
{
public:
    Foo()
    {
        // code to do A
    }
 
    Foo(int value)
    {
        Foo(); // use the above constructor to do A (doesn't work)
        // code to do B
    }
};

hoặc là

class Foo
{
public:
    Foo()
    {
        // code to do A
    }
 
    Foo(int value): Foo() // use the above constructor to do A (doesn't work prior to C++11)
    {
        // code to do B
    }
};

Tuy nhiên, với các trình biên dịch tồn tại trước C++ 11, nếu bạn cố gắng yêu cầu một hàm constructor gọi đến một hàm constructor khác, mã nguồn thường vẫn sẽ được biên dịch bình thường, nhưng chương trình sẽ không hoạt động như bạn mong đợi, và bạn có thể sẽ mất rất nhiều thời gian để tìm hiểu tại sao lại như vậy, kể cả khi có sự hỗ trợ của các công cụ gỡ lỗi (debugger).

(Giải thích thêm: Trước C++ 11, việc gọi tường minh/rõ ràng một hàm constructor từ một hàm constructor khác sẽ tạo ra một đối tượng tạm thời, tiếp theo khởi tạo đối tượng tạm thời này bằng cách sử dụng hàm constructor, sau đó loại bỏ nó, cuối cùng vẫn giữ nguyên đối tượng ban đầu)

3. Sử dụng một hàm riêng biệt

Các hàm constructors được phép gọi tới các hàm non-constructor (tức là các hàm không phải là hàm constructor), bên trong class. Chỉ cần lưu ý rằng bất kỳ biến thành viên nào được sử dụng bởi hàm non-constructor cũng đều phải được khởi tạo trước đó rồi. Mặc dù bạn có thể rất muốn sao chép code từ constructor này sang constructor kia, nhưng việc lặp lại các đoạn code giống hệt nhau sẽ khiến cho class của bạn trở nên khó hiểu và nặng nề hơn, dẫn đến khó bảo trì. Giải pháp tốt nhất cho vấn đề này là tạo ra một hàm non-constructor để thực hiện việc khởi tạo chung, và yêu cầu cả hai hàm constructors gọi đến hàm non-constructor này.

Chúng ta sẽ áp dụng điều này để thay đổi class bên trên thành như sau:

class Foo
{
private:
    void DoA()
    {
        // code to do A
    }
 
public:
    Foo()
    {
        DoA();
    }
 
    Foo(int nValue)
    {
        DoA();
        // code to do B
    }
 
};

Theo cách này, việc lặp lại các đoạn code giống nhau sẽ được giữ ở mức tối thiểu.

Bên cạnh đó, bạn có thể sẽ gặp phải trường hợp mà trong đó, bạn muốn viết một hàm thành viên để khởi tạo lại một class trở về các giá trị mặc định. Bởi vì bạn có thể đã sở hữu một hàm constructor thực hiện việc khởi tạo lại này rồi, nên bây giờ bạn thật sự rất muốn gọi tới hàm constructor này từ hàm thành viên của mình. Tuy nhiên, việc cố gắng gọi trực tiếp một hàm constructor sẽ gây ra lỗi unexpected behavior – hành vi không mong muốn. Nhiều developers thì lại thích copy đoạn code từ hàm constructor vào trong một hàm khởi tạo khác, làm vậy sẽ tạo ra những đoạn code lặp lại, không cần thiết. Giải pháp tốt nhất cho trường hợp này là di chuyển phần code từ hàm constructor sang một hàm mới, và yêu cầu constructor gọi đến hàm mới kia để thực hiện công việc khởi tạo dữ liệu:

class Foo
{
public:
    Foo()
    {
        Init();
    }
 
    Foo(int value)
    {
        Init();
        // do something with value
    }
 
    void Init()
    {
        // code to init Foo
    }
};

Một thực hành tốt nên được áp dụng trong trường hợp này là khai báo một hàm đặt tên là init() có chức năng khởi tạo các biến thành viên bằng các giá trị mặc định, rồi sau đó yêu cầu từng hàm constructor gọi tới hàm init() vừa tạo ở trên, trước khi thực hiện những tác vụ đặc thù liên quan tới các tham số truyền vào của chúng (ý là các constructors). Điều này sẽ giảm thiểu việc lặp lại mã nguồn gây dư thừa và cho phép bạn gọi tường minh hàm init() từ bất cứ nơi nào mà bạn muốn.

Một cảnh báo nhỏ: Hãy cẩn thận khi sử dụng các hàm init() và bộ nhớ được cấp phát động. Bởi vì hàm init() có thể được gọi bởi bất cứ caller (đối tượng gọi hàm) nào, vào bất kỳ lúc nào, nên phần bộ nhớ được cấp phát động có thể đã được khởi tạo rồi trước khi hàm init() được gọi, hoặc chưa được khởi tạo khi hàm init() được gọi. Hãy cẩn thận để có thể xử lý tình huống này một cách phù hợp – nó có thể hơi khó hiểu một chút, bởi vì một con trỏ không null có thể hoặc là đang trỏ đến một phần bộ nhớ đã được cấp phát động, hoặc đang là một con trỏ chưa được khởi tạo (tức là con trỏ này đang trỏ tới một vùng bộ nhớ ngẫu nhiên nào đó – hay còn gọi là địa chỉ rác)!

4. Kỹ thuật delegating – ủy thác các hàm constructors trong C++ 11

Bắt đầu từ C++ 11, các hàm constructors đã được phép gọi tới các hàm constructors khác. Quá trình này được gọi là delegating constructors – tức là ủy thác tác vụ cho các hàm constructors khác thực hiện (hay còn gọi là constructor chaining – thực thi chuỗi các constructor).

Để yêu cầu một hàm constructor A gọi đến một hàm constructor B, ta chỉ cần gọi đến hàm constructor B này trong phần member initializer list – danh sách khởi tạo biến thành viên của hàm constructor A. Đây là một trường hợp trong đó việc gọi trực tiếp đến một hàm constructor khác được chấp nhận. Áp dụng điều này cho ví dụ ở trên:

class Foo
{
private:
 
public:
    Foo()
    {
        // code to do A
    }
 
    Foo(int value): Foo() // use Foo() default constructor to do A
    {
        // code to do B
    }
 
};

Đoạn code trên sẽ hoạt động chính xác như những gì bạn mong đợi. Hãy chắc chắc rằng bạn đang gọi tới hàm constructor mong muốn từ bên trong phần member initializer list – danh sách khởi tạo biến thành viên của constructor gốc, chứ không phải từ bên trong phần thân hàm của constructor gốc.

Dưới đây là một ví dụ khác mô tả việc sử dụng delegating constructor – ủy thác hàm constructor để giảm thiểu các đoạn code dư thừa:

/**
* 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>
#include <iostream>
 
class Employee
{
private:
    int m_id;
    std::string m_name;
 
public:
    Employee(int id=0, const std::string &name=""):
        m_id(id), m_name(name)
    {
        std::cout << "Employee " << m_name << " created.\n";
    }
 
    // Use a delegating constructor to minimize redundant code
    Employee(const std::string &name) : Employee(0, name) { }
};

Class này có 2 hàm constructor, một trong số chúng đã thực hiện ủy thác cho hàm constructor Employee(int, const std::string &). Theo cách này, khối lượng code dư thừa sẽ được giảm thiểu (chúng ta chỉ phải viết một phần thân hàm constructor, thay vì hai).

Có một vài lưu ý bổ sung về việc ủy thác các hàm constructors. Đầu tiên, một hàm constructor mà thực hiện ủy thác công việc/tác vụ của nó cho một hàm constructor khác, thì sẽ không được phép thực hiện bất kỳ phép khởi tạo biến thành viên nào. Có thể hiểu rằng, các hàm constructors của bạn có thể thực hiện ủy thác hoặc khởi tạo, nhưng không phải cả hai.

Thứ hai, có thể xảy ra việc một hàm constructor A thực hiện ủy thác cho một hàm constructor B, rồi hàm constructor B này lại ủy thác trở lại cho hàm constructor A. Điều này sẽ tạo ra một vòng lặp vô hạn, và sẽ khiến chương trình của bạn bị cạn kiệt dung lượng vùng nhớ stack, cuối cùng là sẽ crash. Bạn có thể tránh điều này bằng cách đảm bảo rằng tất cả các hàm constructors của bạn, đến cuối cùng, đều được phân giải thành một hàm constructor không ủy thác. 

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