Khi tất cả các biến thành viên của một class (hoặc struct) đều là public (công khai), chúng ta có thể sử dụng cú pháp khởi tạo tổng hợp (aggregate initialization) để khởi tạo trực tiếp class (hoặc struct) chúng ta có thể sử dụng cách khởi tạo bằng một danh sách (initialization list) hoặc khởi tạo đồng đều (uniform initialization):

class Foo
{
public:
    int m_x;
    int m_y;
};
 
int main()
{
    Foo foo1 = { 4, 5 }; // initialization list
    Foo foo2 { 6, 7 }; // uniform initialization
 
    return 0;
}

Tuy nhiên, ngay sau khi đặt bất kỳ biến thành viên nào thành private, chúng ta sẽ không còn có thể khởi tạo các class theo cách này nữa. Điều này là hợp lý bởi vì: Nếu bạn không thể truy cập trực tiếp vào một biến (bởi vì nó là private), thì bạn cũng không nên khởi tạo trực tiếp nó.

Vậy thì làm thế nào để chúng ta khởi tạo một class với các biến thành viên là private? Câu trả lời là thông qua các constructors – hàm khởi tạo.

1. Constructors

Constructor là một loại hàm thành viên đặc biệt của class, được gọi tự động khi một đối tượng của class đó được khởi tạo. Các constructors thường được sử dụng để khởi tạo các biến thành viên của class theo các giá trị mặc định phù hợp hoặc do người dùng cung cấp, hoặc để thực hiện bất kỳ các bước thiết lập cần thiết nào cho class (ví dụ: Mở file hoặc cơ sở dữ liệu).

Không giống như các hàm thành viên thông thường, các hàm constructors có các quy tắc riêng về việc đặt tên:

  • Các hàm constructors phải có cùng tên với tên class (phải giống cả về việc ký tự viết hoa hay viết thường)
  • Các hàm constructor không có kiểu trả về (kể cả là kiểu void)

2. Constructors mặc định

Một constructor không có tham số truyền vào (hoặc có tham số mà tất cả chúng đều có giá trị mặc định) được gọi là constructor mặc định. Khi sử dụng một constructor, nếu không có giá trị khởi tạo nào do người dùng cung cấp được truyền cho constructor này, thì constructor mặc định sẽ được gọi.

Dưới đây là ví dụ về một class có một constructor mặc định:

/**
* 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 Fraction
{
private:
    int m_numerator;
    int m_denominator;
 
public:
    Fraction() // default constructor
    {
         m_numerator = 0;
         m_denominator = 1;
    }
 
    int getNumerator() { return m_numerator; }
    int getDenominator() { return m_denominator; }
    double getValue() { return static_cast<double>(m_numerator) / m_denominator; }
};
 
int main()
{
    Fraction frac; // Since no arguments, calls Fraction() default constructor
    std::cout << frac.getNumerator() << "/" << frac.getDenominator() << '\n';
 
    return 0;
}

Class này được thiết kế để giữ một giá trị phân số dưới dạng tử số và mẫu số kiểu integer. Một constructor mặc định có tên là Fraction (cùng tên với class) đã được định nghĩa bên trong class này.

Bởi vì chúng ta đang khởi tạo một đối tượng thuộc kiểu Fraction mà không có đối số nào được truyền vào, nên constructor mặc định sẽ được gọi ngày sau khi bộ nhớ được cấp phát cho đối tượng này, và đối tượng của chúng ta sẽ được khởi tạo.

Đoạn chương trình ví dụ ở trên sẽ in ra kết quả:

0/1

Lưu ý rằng tử số (numerator) và mẫu số (denominator) của chúng ta đã được khởi tạo bằng các giá trị mà ta đã thiết lập bên trong constructor mặc định! Nếu không có constructor mặc định, tử số và mẫu số sẽ có các giá trị rác cho đến khi chúng ta gán rõ ràng cho chúng các giá trị hợp lý, hoặc là khởi tạo chúng bằng các phương tiện khác (hãy nhớ rằng: Các biến thuộc các kiểu dữ liệu cơ bản đều không được khởi tạo theo mặc định).

3. Khởi tạo trực tiếp và khởi tạo đồng đều bằng các constructors có tham số

Mặc dù constructor mặc định thật sự tuyệt vời trong việc đảm bảo các class của chúng ta đều được khởi tạo với các giá trị mặc định hợp lý, tuy nhiên trong thực tế, chúng ta sẽ thường muốn rằng các thể hiện (instances) của class có được các giá trị cụ thể do chính chúng ta cung cấp. Và thật may mắn là các hàm constructors cũng có thể được khai báo với các tham số truyền vào. Dưới đây là một ví dụ về một constructor nhận vào hai tham số kiểu integer, được sử dụng để khởi tạo tử số (numerator) và mẫu số (denominator):

/**
* 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 <cassert>
 
class Fraction
{
private:
    int m_numerator;
    int m_denominator;
 
public:
    Fraction() // default constructor
    {
         m_numerator = 0;
         m_denominator = 1;
    }
 
    // Constructor with two parameters, one parameter having a default value
    Fraction(int numerator, int denominator=1)
    {
        assert(denominator != 0);
        m_numerator = numerator;
        m_denominator = denominator;
    }
 
    int getNumerator() { return m_numerator; }
    int getDenominator() { return m_denominator; }
    double getValue() { return static_cast<double>(m_numerator) / m_denominator; }
};

Lưu ý rằng, hiện tại chúng ta đang có 2 hàm constructors bên trong class Fraction: Một hàm constructor mặc định sẽ được gọi trong trường hợp mặc định và một hàm constructor thứ hai nhận vào 2 tham số. Hai hàm constructor này có thể cùng tồn tại một cách hòa bình trong cùng một class nhờ vào khả năng function overloading (nạp chồng hàm) của lập trình hướng đối tượng. Trong thực tế, bạn có thể định nghĩa ra bao nhiêu hàm constructor cũng được, miễn là mỗi hàm đều phải có một ký hiệu riêng (unique signature) (ký hiệu riêng của một hàm constructor bao gồm các đặc điểm về: Số lượng các tham số và kiểu dữ liệu của các tham số này).

Vậy thì làm thế nào để chúng ta có thể sử dụng được constructor có tham số này? Rất đơn giản! Chúng ta có thể sử dụng cặp dấu ngoặc nhọn hoặc là cú pháp khởi tạo trực tiếp:

Fraction fiveThirds{ 5, 3 }; // Brace initialization, calls Fraction(int, int)
Fraction threeQuarters(3, 4); // Direct initialization, also calls Fraction(int, int)

Kể từ C++ 11, chúng ta sẽ ưu tiên sử dụng cú pháp khởi tạo constructor bằng cặp dấu ngoặc nhọn hơn. Có một loại hàm constructor đặc biệt khác có thể khiến cho việc khởi tạo bằng cặp dấu ngoặc nhọn làm thứ gì đó rất khác, trong trường hợp đó chúng ta sẽ phải sử dụng cú pháp khởi tạo trực tiếp. Những loại constructor này sẽ được nói đến sau.

Lưu ý rằng, chúng ta đã thiết lập một giá trị mặc định cho tham số truyền vào thứ hai của hàm constructor Fraction trong class cùng tên, do đó đoạn code dưới đây cũng sẽ hợp lệ:

Fraction six{ 6 }; // calls Fraction(int, int) constructor, second parameter uses default value

Các giá trị mặc định dành cho hàm constructors hoạt động giống hệt như khi chúng được sử dụng cho các khác hàm khác, vì vậy trong trường hợp bên trên, khi chúng ta gọi hàm six(6), hàm Fraction(int, int) sẽ được gọi, trong đó tham số thứ hai sẽ nhận giá trị mặc định đã được thiết lập sẵn là 1.

Quy tắc: Nên ưu tiên sử dụng cú pháp khởi tạo constructor bằng cặp dấu ngoặc nhọn (brace initialization) để khởi tạo các đối tượng của class.

4. Sao chép khởi tạo bằng cách sử dụng toán từ “bằng với class”

Phần hiểu thêm

Phần này sẽ chỉ có liên quan nếu bạn đang sử dụng một chuẩn trình biên dịch cũ hơn C++ 11.

Giống như với các biến thuộc các kiểu dữ liệu cơ bản (fundamental variables), ta cũng có thể khởi tạo class bằng cách sử dụng sao chép khởi tạo:

Fraction six = Fraction{ 6 }; // Copy initialize a Fraction, will call Fraction(6, 1)
Fraction seven = 7; // Copy initialize a Fraction.  The compiler will try to find a way to convert 7 to a Fraction, which will invoke the Fraction(7, 1) constructor.

Tuy nhiên, chúng tôi khuyên bạn nên tránh sử dụng dạng thức khởi tạo này đối với các class, bởi vì nó có thể kém hiệu quả hơn so với những cách khởi tạo đối tượng khác. Mặc dù khởi tạo trực tiếp, khởi tạo đồng đều và khởi tạo theo kiểu sao chép đều hoạt động giống hệt nhau khi làm việc với các kiểu dữ liệu cơ bản, nhưng khởi tạo bằng cách sao chép sẽ không hoạt động giống nhau khi làm việc với các kiểu class (mặc dù kết quả cuối cùng thường giống nhau). Chúng ta sẽ tìm hiểu những điểm khác biệt này chi tiết hơn trong một chương khác.

5. Giảm số lượng hàm constructors bên trong class

Trong phần khai báo hai hàm constructor của class Fraction ở ví dụ trên, hàm constructor mặc định thực sự hơi dư thừa. Chúng ta có thể đơn giản hóa class này như sau:

#include <cassert>
 
class Fraction
{
private:
    int m_numerator;
    int m_denominator;
 
public:
    // Default constructor
    Fraction(int numerator=0, int denominator=1)
    {
        assert(denominator != 0);
 
        m_numerator = numerator;
        m_denominator = denominator;
    }
 
    int getNumerator() { return m_numerator; }
    int getDenominator() { return m_denominator; }
    double getValue() { return static_cast<double>(m_numerator) / m_denominator; }
};

Mặc dù hàm constructor này vẫn chỉ là một hàm constructor mặc định, nhưng hiện tại nó đã được định nghĩa theo cách mà nó có thể chấp nhận một hoặc hai giá trị do người dùng cung cấp, đều được.

Fraction zero; // will call Fraction(0, 1)
Fraction zero{}; // will call Fraction(0, 1)
Fraction six{ 6 }; // will call Fraction(6, 1)
Fraction fiveThirds{ 5, 3 }; // will call Fraction(5, 3)

Khi cài đặt các hàm constructors của mình, bạn hãy cố gắng suy nghĩ về việc liệu rằng bạn có thể giảm được số lượng các hàm constructor thông qua việc thiết lập một cách thông minh các giá trị mặc định cho các tham số truyền vào của hàm constructor hay không.

6. Một lưu ý về các tham số mặc định của hàm constructor

Các quy tắc xung quanh việc định nghĩa và gọi các hàm có tham số truyền vào mặc định, cũng được áp dụng cho các hàm constructor. Có thể tóm tắt lại như sau: Khi định nghĩa một hàm với các tham số truyền vào mặc định, tất cả các tham số truyền vào mặc định phải nằm sau mọi tham số không mặc định (non-default parameter). Ví dụ: Không thể có một tham số không mặc định nằm sau một tham số mặc định.

Điều này có thể tạo ra các kết quả không mong đợi cho các class có nhiều tham số mặc định thuộc nhiều kiểu dữ liệu khác nhau, ta cùng xem ví dụ sau:

class Something
{
public:
	// Default constructor
	Something(int n = 0, double d = 1.2) // allows us to construct a Something(int, double), Something(int), or Something()
	{
	}
};
 
int main()
{
	Something s1 { 1, 2.4 }; // calls Something(int, double)
	Something s2 { 1 }; // calls Something(int, double)
	Something s3 {}; // calls Something(int, double)
 
	Something s4 { 2.4 }; // will not compile, as there's no constructor to handle Something(double)
 
	return 0;
}

Với biến s4, chúng ta đã cố gắng tạo ra một đối tượng Something bằng cách chỉ cung cấp một giá trị kiểu double là 2.4. Câu lệnh này sẽ không thể biên dịch, bởi vì các quy tắc về việc so khớp các đối số truyền vào với các tham số mặc định sẽ không cho phép chúng ta bỏ qua một tham số không phải nằm ở ngoài cùng (trong trường hợp này, chính là tham số truyền vào kiểu int nằm ở ngoài cùng bên trái).

Nếu muốn có thể tạo ra một đối tượng Something với chỉ một giá trị kiểu double, ta sẽ cần thêm một constructor (không mặc định) thứ hai:

class Something
{
public:
	// Default constructor
	Something(int n = 0, double d = 1.2) // allows us to construct a Something(int, double), Something(int), or Something()
	{
	}
 
	Something(double d)
	{
	}
};
 
int main()
{
	Something s1 { 1, 2.4 }; // calls Something(int, double)
	Something s2 { 1 }; // calls Something(int, double)
	Something s3 {}; // calls Something(int, double)
 
	Something s4 { 2.4 }; // calls Something(double)
 
	return 0;
}

7. Một hàm constructor mặc định được tạo ngầm

Nếu class của bạn không có hàm constructor nào, C++ sẽ tự động sinh ra một hàm constructor mặc định ở trạng thái public cho bạn. Hàm constructor được tạo tự động này đôi khi được gọi là một hàm constructor ngầm (hoặc hàm constructor được tạo ngầm).

Xét class dưới đây:

class Date
{
private:
    int m_year;
    int m_month;
    int m_day;
 
    // No user-provided constructors, the compiler generates a default constructor.
};

Class này không có hàm constructor nào. Do đó, trình biên dịch sẽ sinh ra một hàm constructor cho phép chúng ta tạo ra một đối tượng Date mà không cần đối số truyền vào nào.

Hàm constructor ngầm đặc biệt này cho phép chúng ta tạo ra một đối tượng Date không có đối số, nhưng không khởi tạo bất kỳ biến thành viên nào (bởi vì tất cả các biến thành viên này đều thuộc các kiểu dữ liệu cơ bản, và chúng không được khởi tạo khi đối tượng được tạo ra). Nếu class Date có các biến thành viên thuộc kiểu dữ liệu class nào đó, ví dụ như std::string, các hàm constructors của những biến thành này sẽ tự động được gọi.

Để đảm bảo rằng các biến thành viên đều được khởi tạo, chúng ta có thể khởi tạo chúng ngay trong phần khai báo của chúng.

class Date
{
private:
    int m_year{ 1900 };
    int m_month{ 1 };
    int m_day{ 1 };
};

Mặc dù bạn không thể nhìn thấy hàm constructor được tạo ngầm, nhưng bạn vẫn có thể chứng minh được sự tồn tại của nó:

class Date
{
private:
    int m_year{ 1900 };
    int m_month{ 1 };
    int m_day{ 1 };
 
    // No constructor provided, so C++ creates a public default constructor for us
};
 
int main()
{
    Date date{}; // calls implicit constructor
 
    return 0;
}

Đoạn code trên sẽ được biên dịch thành công, bởi vì đối tượng date sẽ sử dụng hàm constructor ngầm (đang ở chế độ public).

Nếu class của bạn có bất kỳ hàm constructor nào khác, hàm constructor được sinh ngầm sẽ không còn khả dụng nữa. Ví dụ:

class Date
{
private:
    int m_year{ 1900 };
    int m_month{ 1 };
    int m_day{ 1 };
 
public:
    Date(int year, int month, int day) // normal non-default constructor
    {
        m_year = year;
        m_month = month;
        m_day = day;
    }
 
    // No implicit constructor provided because we already defined our own constructor
};
 
int main()
{
    Date date{}; // error: Can't instantiate object because default constructor doesn't exist and the compiler won't generate one
    Date today{ 2020, 1, 19 }; // today is initialized to Jan 19th, 2020
 
    return 0;
}

Để có thể tạo ra một đối tượng Date không có đối số, ta cần:

  • Hoặc là thêm các đối số mặc định vào hàm constructor, 
  • hoặc là nhờ trình biên dịch tạo ra thêm một hàm constructor mặc định không có tham số, ngay cả khi đã có các hàm constructors khác do người dùng cung cấp,  
  • hoặc là ta tự khai báo tường minh thêm một hàm constructor mặc định:
class Date
{
private:
    int m_year{ 1900 };
    int m_month{ 1 };
    int m_day{ 1 };
 
public:
    // Tell the compiler to create a default constructor, even if
    // there are other user-provided constructors.
    Date() = default;
 
    Date(int year, int month, int day) // normal non-default constructor
    {
        m_year = year;
        m_month = month;
        m_day = day;
    }
};
 
int main()
{
    Date date{}; // date is initialized to Jan 1st, 1900
    Date today{ 2020, 10, 14 }; // today is initialized to Oct 14th, 2020
 
    return 0;
}

Việc sử dụng câu lệnh “= default” thì tương đương với việc ta tự khai báo thêm một hàm constructor mặc định không nhận tham số nào, và có phần thân hàm trống. Sự khác biệt duy nhất là câu lệnh “= default” sẽ cho phép chúng ta khởi tạo các biến thành viên một cách an toàn ngay cả khi chúng không có initializer nào:

/**
* 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/
*/

class Date
{
private:
    // Note: No initializations at member declarations
    int m_year;
    int m_month;
    int m_day;
 
public:
    // Explicitly defaulted constructor
    Date() = default;
};
 
class Date2
{
private:
    // Note: No initializations at member declarations
    int m_year;
    int m_month;
    int m_day;
 
public:
    // Empty user-provided constructor
    Date2() {};
};
 
int main()
{
    Date today{}; // today is 0, 0, 0
    Date2 tomorrow{}; // tomorrows's members are uninitialized
 
    return 0;
}

Sử dụng câu lệnh  “= default” có vẻ dài dòng hơn so với việc tự viết một hàm constructor với phần thân hàm trống, nhưng nó giúp thể hiện rõ hơn ý định của bạn là gì (để tạo ra một hàm constructor mặc định), và nó an toàn hơn. Câu lệnh “= default” cũng làm việc cho các hàm constructor đặc biệt khác, mà chúng ta sẽ nói tới trong tương lai.

Quy tắc: Nếu bạn đã có nhiều hàm constructors khác nhau bên trong class của mình, và bạn vẫn cần tới một constructor mặc định có phần thân hàm trống (tức là nó không thực hiện tác vụ nào cả), hãy sử dụng câu lệnh “= default”.

8. Class chứa class

Một class có thể chứa nhiều class khác dưới dạng các biến thành viên. Theo mặc định, khi class nằm ở ngoài cùng được khởi tạo, các hàm constructors mặc định của các biến thành viên sẽ được gọi. Điều này sẽ xảy ra trước khi phần thân hàm của hàm constructor mặc định của class nằm ở ngoài cùng được thực thi.

Điều này có thể được mô tả thông qua đoạn code sau:

#include <iostream>
 
class A
{
public:
    A() { std::cout << "A\n"; }
};
 
class B
{
private:
    A m_a; // B contains A as a member variable
 
public:
    B() { std::cout << "B\n"; }
};
 
int main()
{
    B b;
    return 0;
}

Kết quả in ra là:

A
B

Khi biến b được khởi tạo/xây dựng, hàm constructor B() sẽ được gọi. Trước khi phần thân hàm của constructor này được thực thi, biến m_a sẽ được khởi tạo trước nhờ lời gọi tới hàm constructor mặc định của class A. Do đó, “A” sẽ được in ra màn hình đầu tiên. Sau đó, điều khiển sẽ được trả về lại cho hàm constructor của class B, và phần thân hàm của constructor B sẽ được thực thi.

Điều này thực sự hợp lý, bởi vì constructor B() có thể muốn sử dụng biến m_a, vì vậy biến m_a tốt hơn hết là nên được khởi tạo khước khi đoạn code trong phần thân hàm của constructor B() được thực thi. 

Sự khác biệt so với ví dụ cuồi trong phần trước đó là biến m_a thuộc một kiểu dữ liệu class chứ không phải kiểu dữ liệu thông thường. Các biến thành viên của kiểu dữ liệu class sẽ được khởi tạo ngay cả khi chúng ta không khởi tạo rõ ràng cho chúng.

Trong bài học tiếp theo, chúng ta sẽ nói về cách để khởi tạo các biến thành viên bên trong class này.

9. Một số ghi chú về constructor

Nhiều lập trình viên mới thường bối rối về việc liệu rằng các hàm constructors có tạo ra các đối tượng hay không. Và câu trả lời là chúng không – Trình biên dịch sẽ thiết lập cấp phát bộ nhớ cho đối tượng, trước khi lời gọi hàm constructor được thực thi.

Hàm constructor thực sự được sử dụng để phục vụ cho 2 mục đích. 

  • Thứ nhất, hàm constructor xác định ai được phép tạo ra đối tượng. Điều này có nghĩa là, một đối tượng của một class chỉ có thể được tạo ra nếu tìm thấy được một constructor với các tham số truyền vào trùng khớp (về mặt số lượng và kiểu dữ liệu) với các đối số được người dùng cung cấp khi gọi hàm constructor để khởi tạo đối tượng này.
  • Thứ hai, các hàm constructors có thể được sử dụng để khởi tạo các đối tượng. Việc liệu rằng hàm constructor có thực sự thực hiện việc khởi tạo hay không là tùy thuộc vào người lập trình viên. Việc tạo ra một constructor mà không thực hiện việc khởi tạo nào là hoàn toàn hợp lệ về mặt cú pháp (constructor vẫn phục vụ mục đích cho phép tạo ra đối tượng, như ở trên).

Tuy nhiên, có thể rút ra một số thực hành tốt nhất ở đây là: 

  • Nên khởi tạo tất cả các biến cục bộ
  • Nên khởi tạo tất cả các biến thành viên khi thực hiện tạo đối tượng

Hai điều trên có thể được thực hiện thông qua một hàm constructor, hoặc thông qua các phương tiện khác mà chúng ta sẽ tìm hiểu trong các bài học sắp tới.

Thực hành tốt nhất: Hãy khởi tạo tất cả các biến thành viên trong đối tượng của bạn.

Cuối cùng, các constructors chỉ được sử dụng để khởi tạo khi đối tượng đã được tạo ra. Bạn không nên cố gắng gọi một hàm constructor để khởi tạo lại một đối tượng đang tồn tại. Mặc dù code vẫn được biên dịch thành công, nhưng các kết quả sẽ không như bạn đã dự định (thay vào đó, trình biên dịch sẽ tạo ra một đối tượng tạm thời và sau đó loại bỏ nó đi).

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