Hàm khởi tạo và hàm hủy trong C++
Hàm khởi tạo (constructor) chạy tự động khi tạo đối tượng, dùng để gán trạng thái ban đầu. Hàm hủy (destructor) chạy tự động khi hủy đối tượng, dùng để dọn tài nguyên. Đây là nền của RAII (tài nguyên gắn với vòng đời đối tượng).
Đặt vấn đề
Không có hàm khởi tạo, mỗi đối tượng mới sinh ra là một quả bom hẹn giờ. Bạn khai báo
TaiKhoan tk; và các thuộc tính kiểu cơ bản của nó chứa rác: bất cứ giá trị nào còn sót
trong vùng nhớ đó từ lần dùng trước. Số dư có thể là hai tỉ, có thể là âm. Bạn phải nhớ
gọi tay một hàm khoiTao() ở mọi nơi tạo đối tượng, và chỉ cần quên một chỗ là hỏng.
Ở đầu kia của vòng đời, vấn đề còn tệ hơn. Giả sử đối tượng mở một tệp, cấp phát một mảng
động, hay giữ một kết nối mạng. Ai đóng tệp? Ai gọi delete? Nếu câu trả lời là "lập
trình viên phải nhớ gọi donDep()", bạn đã thua từ đầu: người ta sẽ quên, và ngay cả
người không quên cũng thua khi hàm thoát sớm bằng return hoặc khi một ngoại lệ nhảy qua
đoạn dọn dẹp.
C++ giải quyết cả hai đầu bằng một ý tưởng: gắn việc chuẩn bị và việc dọn dẹp vào vòng đời của đối tượng. Hàm khởi tạo chạy tự động lúc đối tượng sinh ra, hàm hủy chạy tự động lúc nó chết, và trình biên dịch bảo đảm hàm hủy chạy trong mọi đường thoát, kể cả khi có ngoại lệ. Bạn không cần nhớ gì cả. Đó là RAII.
Ý tưởng cốt lõi
- Constructor
File(string t)đảm bảo đối tượng sinh ra là đã hợp lệ. - Destructor
~File()chạy khi đối tượng ra khỏi phạm vi (hoặc bịdelete). - Thứ tự hủy ngược thứ tự tạo (vào sau ra trước).
Mã nguồn
#include <iostream>
#include <string>
using namespace std;
class File {
public:
string ten;
File(string t) : ten(t) { cout << "Mo " << ten << "\n"; } // khởi tạo
~File() { cout << "Dong " << ten << "\n"; } // hủy
};
int main() {
File a("data.txt");
{
File b("log.txt"); // sống trong khối {}
} // b ra khỏi khối -> hủy
cout << "...\n";
} // a ra khỏi main -> hủy
Thử ngay: xem khởi tạo và hủy chạy từng bước
1class File {2public:3 string ten;4 File(string t) : ten(t) { cout << "Mo " << ten << "\n"; }5 ~File() { cout << "Dong " << ten << "\n"; }6};78int main() {9 File a("data.txt");10 {11 File b("log.txt");12 }13 cout << "...\n";14}
Mo data.txt
Các loại hàm khởi tạo
Một lớp có thể có nhiều hàm khởi tạo, phân biệt nhau bằng danh sách tham số (nạp chồng). Ba loại hay gặp nhất:
- Mặc định: không tham số,
Diem(). Dùng khi tạoDiem d;hay khi tạo mảng đối tượng. - Có tham số:
Diem(int x, int y). Dùng khi biết trước giá trị ban đầu. - Sao chép (copy constructor):
Diem(const Diem& khac). Chạy khi tạo một đối tượng mới từ một đối tượng đã có, gồm cả lúc truyền theo giá trị và lúc trả về theo giá trị.
#include <iostream>
using namespace std;
class Diem {
public:
int x, y;
Diem() : x(0), y(0) { cout << "ctor mac dinh\n"; } // default
Diem(int a, int b) : x(a), y(b) { cout << "ctor tham so\n"; } // parameterized
Diem(const Diem& k) : x(k.x), y(k.y) { cout << "ctor sao chep\n"; } // copy
};
int main() {
Diem a; // "ctor mac dinh"
Diem b(3, 4); // "ctor tham so"
Diem c = b; // "ctor sao chep": c is built from b, this is NOT an assignment
cout << c.x << "," << c.y << "\n"; // 3,4
}
Chú ý dòng Diem c = b;. Dấu bằng ở đây gây hiểu lầm: đó không phải phép gán mà là
khởi tạo, nên hàm khởi tạo sao chép chạy chứ không phải toán tử gán.
Nếu lớp không có hàm khởi tạo nào, trình biên dịch tự sinh cho bạn một hàm mặc định rỗng.
Nhưng chỉ cần bạn tự viết một hàm khởi tạo có tham số, hàm mặc định đó biến mất. Từ
đó Diem d; không còn biên dịch được, và mảng Diem ds[10]; cũng hỏng theo. Nếu vẫn cần
nó, hãy viết thêm hoặc khai báo Diem() = default;.
Danh sách khởi tạo
Hai cách viết dưới đây trông giống nhau nhưng chạy khác nhau:
Sach(string t) { tua = t; } // (1) tua is default-built first, then ASSIGNED
Sach(string t) : tua(t) { } // (2) tua is BUILT directly from t
Cách (1) làm hai việc: trước khi thân hàm chạy, tua đã được xây bằng hàm khởi tạo mặc
định của string (thành chuỗi rỗng), rồi thân hàm mới gán đè lên. Cách (2) xây thẳng tua
từ t, bỏ hẳn bước thừa.
Với thành viên kiểu int hay double, khác biệt này gần như bằng không, nên nhiều người
tưởng hai cách như nhau. Nhưng với thành viên là string, vector, hay bất cứ lớp nào có
hàm khởi tạo và hàm hủy thật, cách (1) trả giá gấp đôi: một lần dựng đối tượng rỗng vô ích,
rồi một lần gán đè để vứt luôn cái vừa dựng. Khi lớp có nhiều thành viên nặng và hàm khởi
tạo bị gọi trong vòng lặp, khoản lãng phí đó cộng dồn rất nhanh. Đó là lý do danh sách khởi
tạo được xem là mặc định nên theo, còn gán trong thân hàm là ngoại lệ chỉ dùng khi thật sự
cần tính toán gì đó trước khi đặt giá trị.
Quan trọng hơn, có ba trường hợp bắt buộc dùng danh sách khởi tạo vì phép gán không thể thay thế:
- Thành viên là hằng:
const int ma;. Hằng chỉ được đặt giá trị đúng một lần lúc sinh ra. - Thành viên là tham chiếu:
Lop& lop;. Tham chiếu phải gắn với đối tượng ngay lúc ra đời và không thể gắn lại. - Thành viên là đối tượng của lớp không có hàm khởi tạo mặc định. Không có gì để xây nó trước, nên buộc phải xây thẳng từ danh sách.
#include <iostream>
#include <string>
using namespace std;
class Lop {
public:
string ten;
Lop(const string& t) : ten(t) {} // no default constructor here
};
class SinhVien {
private:
const int maSo; // const: must be set in the init list
Lop& lop; // reference: must be bound in the init list
string ten;
public:
SinhVien(int ma, Lop& l, const string& t)
: maSo(ma), lop(l), ten(t) {} // the only legal way to set these three
void in() const {
cout << maSo << " " << ten << " lop " << lop.ten << "\n";
}
};
int main() {
Lop k24("K24-CNTT");
SinhVien sv(1001, k24, "An");
sv.in(); // 1001 An lop K24-CNTT
}
class Mang {
int* p; // declared FIRST, so it is initialized FIRST
int n; // declared second
public:
Mang(int soPhanTu) : n(soPhanTu), p(new int[n]) { } // BUG: n is still garbage here
};
Đọc danh sách khởi tạo, ta tưởng n được đặt bằng soPhanTu trước, rồi mới cấp phát
new int[n]. Sai. Thứ tự trong danh sách không quyết định gì cả: thành viên luôn được
khởi tạo theo thứ tự khai báo trong lớp. Ở đây p khai báo trước nên new int[n]
chạy trước, lúc đó n còn là rác, và bạn cấp phát một mảng với kích thước ngẫu nhiên.
Cách tránh: luôn viết danh sách khởi tạo theo đúng thứ tự khai báo thành viên, và bật
cảnh báo -Wreorder (có sẵn trong -Wall) để trình biên dịch nhắc bạn khi hai thứ tự
lệch nhau.
Hàm hủy và thứ tự gọi
Hàm hủy có tên là dấu ngã cộng tên lớp, ~File(). Nó không có tham số, không có kiểu trả
về, và mỗi lớp chỉ có đúng một hàm hủy (không nạp chồng được). Nó chạy khi:
- đối tượng cục bộ ra khỏi phạm vi,
- đối tượng cấp phát động bị
delete, - một ngoại lệ được ném và ngăn xếp được tháo gỡ,
- đối tượng thành viên bị hủy cùng đối tượng chứa nó.
Thứ tự đầy đủ của một vòng đời:
- Cấp phát bộ nhớ cho đối tượng.
- Các thành viên được khởi tạo, theo thứ tự khai báo trong lớp.
- Thân hàm khởi tạo chạy.
- Đối tượng sống và làm việc.
- Thân hàm hủy chạy.
- Các thành viên bị hủy, theo thứ tự ngược với thứ tự khai báo.
Nói ngắn gọn: xây từ trong ra, dỡ từ ngoài vào. Giữa các đối tượng cùng phạm vi thì cũng vậy, cái tạo sau bị hủy trước.
Xem chạy từng bước: thứ tự dựng và hủy theo chuỗi kế thừa
Bản trace ở đầu bài cho thấy thứ tự dựng và hủy giữa các đối tượng cùng phạm vi. Bản trace
sau đây đi vào một đối tượng duy nhất nhưng có kế thừa: hàm dựng chạy theo chuỗi cha trước
con, còn hàm hủy chạy ngược lại con trước cha. Vì đối tượng lớp con bị delete qua con trỏ
lớp cha nên hủy tử lớp cơ sở phải là virtual.
1#include <iostream>2using namespace std;3struct A {4 A() { cout << "A()"; }5 virtual ~A() { cout << "~A()"; }6};7struct B : A {8 B() { cout << "B()"; }9 ~B() override { cout << "~B()"; }10};11int main() {12 A* p = new B();13 delete p;14}
A()Khi nào cần tự viết hàm hủy
Đây là câu hỏi thực tế nhất của bài, và câu trả lời gọn hơn nhiều sinh viên tưởng: chỉ
khi lớp của bạn sở hữu một tài nguyên thô mà không ai khác dọn hộ. Tài nguyên thô là bộ
nhớ cấp bằng new, tệp mở bằng fopen, kết nối mạng, khóa, cửa sổ đồ họa.
Nếu lớp chỉ chứa int, string, vector, hay các đối tượng đã tự lo phần dọn dẹp, thì
đừng viết hàm hủy. Hàm hủy do trình biên dịch sinh ra vốn đã gọi hàm hủy của từng
thành viên, và string với vector tự giải phóng bộ nhớ của chúng.
Lớp dưới đây sở hữu một mảng động, nên nó cần hàm hủy thật.
#include <iostream>
using namespace std;
class MangDong {
private:
int* du_lieu; // owned raw resource
int n;
public:
MangDong(int soPhanTu) : du_lieu(new int[soPhanTu]), n(soPhanTu) {
for (int i = 0; i < n; ++i) du_lieu[i] = 0;
cout << "Cap phat " << n << " so\n";
}
~MangDong() { // destructor: release what the constructor acquired
delete[] du_lieu; // use delete[] for new[], not plain delete
cout << "Giai phong " << n << " so\n";
}
void dat(int i, int v) { if (i >= 0 && i < n) du_lieu[i] = v; }
int lay(int i) const { return (i >= 0 && i < n) ? du_lieu[i] : 0; }
};
int main() {
{
MangDong m(5); // "Cap phat 5 so"
m.dat(2, 42);
cout << m.lay(2) << "\n"; // 42
} // leaving the block: "Giai phong 5 so" runs automatically
cout << "Ra khoi khoi\n";
}
Lớp MangDong ở trên còn một cái bẫy: nếu ai đó viết MangDong m2 = m1;, hàm khởi tạo
sao chép mặc định sẽ chép giá trị con trỏ, khiến hai đối tượng cùng trỏ vào một mảng.
Khi cả hai bị hủy, delete[] chạy hai lần trên cùng vùng nhớ và chương trình sập. Quy tắc
kinh nghiệm (rule of three): lớp nào cần hàm hủy tự viết thì gần như chắc chắn cũng cần
hàm khởi tạo sao chép và toán tử gán tự viết. Cách tránh toàn bộ rắc rối này: dùng
std::vector hoặc std::unique_ptr thay cho con trỏ thô, khi đó bạn không cần viết hàm
hủy nào cả.
Vì sao hàm hủy lớp cơ sở nên là ảo
Có một tình huống mà thứ tự hủy tưởng như đã rõ lại đổ vỡ hoàn toàn: khi bạn xóa một đối
tượng lớp con thông qua một con trỏ kiểu lớp cơ sở. Đây là cách viết cực kỳ thường gặp trong
đa hình, ta giữ mọi đối tượng trong một Co* rồi delete chúng theo cùng một lối. Nhưng
nếu hàm hủy của lớp cơ sở không ảo, delete p chỉ nhìn vào kiểu tĩnh của p, gọi đúng
hàm hủy của lớp cơ sở và bỏ qua hàm hủy lớp con. Phần tài nguyên mà lớp con cấp phát
không được dọn, và ta có một rò rỉ ngay giữa mã trông rất sạch sẽ.
#include <iostream>
using namespace std;
struct Co {
// ~Co() { cout << "huy Co\n"; } // non-virtual: ~Con() would be skipped
virtual ~Co() { cout << "huy Co\n"; } // virtual: dispatches to the real type
};
struct Con : Co {
int* tai_nguyen;
Con() : tai_nguyen(new int[100]) {}
~Con() override { delete[] tai_nguyen; cout << "huy Con\n"; }
};
int main() {
Co* p = new Con(); // a base pointer holding a derived object
delete p; // with virtual ~Co: prints "huy Con" then "huy Co"
}
Với hàm hủy ảo, delete p tra bảng phương thức ảo, thấy kiểu động là Con, nên chạy
~Con() trước (giải phóng mảng động), rồi mới tới ~Co(). Đúng quy tắc dỡ từ ngoài vào.
Nếu gỡ chữ virtual đi, chỉ ~Co() chạy, mảng một trăm phần tử rò rỉ, và tệ hơn, theo
chuẩn C++ việc delete một đối tượng lớp con qua con trỏ cơ sở không có hàm hủy ảo là
hành vi không xác định, nghĩa là trình biên dịch được phép làm bất cứ điều gì.
Gốc rễ của lỗi này là sự lệch pha giữa kiểu tĩnh và kiểu động: delete qua con trỏ cơ sở
chỉ biết kiểu tĩnh, còn đối tượng thật lại thuộc lớp con. Chỉ hàm hủy ảo mới bắc được cây
cầu đó. Quy tắc thực hành: hễ một lớp có bất kỳ hàm ảo nào, hoặc bạn định cho người khác
kế thừa và xóa qua con trỏ cơ sở, hãy khai báo hàm hủy của nó là virtual. Ngược lại, một
lớp không bao giờ dùng làm cơ sở thì không cần hàm hủy ảo, vì bảng phương thức ảo cũng tốn
thêm bộ nhớ cho mỗi đối tượng.
Tóm tắt
- Hàm khởi tạo bảo đảm đối tượng sinh ra là đã hợp lệ. Có mặc định, có tham số, và sao chép.
- Tự viết một hàm khởi tạo thì hàm mặc định biến mất, trừ khi khai báo
= default. - Ưu tiên danh sách khởi tạo. Bắt buộc dùng nó với thành viên
const, tham chiếu, và lớp không có hàm khởi tạo mặc định. - Thành viên khởi tạo theo thứ tự khai báo, hủy theo thứ tự ngược lại.
- Hàm hủy chạy tự động ở mọi đường thoát. Đó là điều làm RAII an toàn hơn việc gọi tay.
- Chỉ tự viết hàm hủy khi lớp sở hữu tài nguyên thô. Có
vectorhaystringlo giúp thì đừng viết.
Bài tập thực hành
Bài tập 1: tham số mặc định cho hàm khởi tạo
Viết lớp TaiKhoan (tài khoản) có hai trường: ten (string) và soDu (int). Thêm hàm khởi tạo nhận ten và soDu, trong đó soDu có giá trị mặc định là 0. Khi tạo đối tượng, in ra dòng Mo tai khoan <ten> voi so du <soDu>. Hãy tạo một tài khoản chỉ truyền tên và một tài khoản truyền cả hai tham số, rồi quan sát kết quả.
Lời giải chi tiết
Tham số mặc định cho phép gọi hàm khởi tạo mà không cần truyền đủ mọi đối số. Khi khai báo TaiKhoan(string t, int s = 0), trình biên dịch sẽ tự điền 0 cho soDu nếu lời gọi bỏ trống tham số này. Lưu ý chỉ đặt giá trị mặc định ở khai báo (trong khai báo lớp), không lặp lại nó ở phần định nghĩa để tránh lỗi định nghĩa hai lần.
Trong main, lời gọi TaiKhoan a("An") dùng giá trị mặc định nên soDu bằng 0, còn TaiKhoan b("Binh", 500) truyền đủ nên soDu bằng 500.
#include <iostream>
#include <string>
using namespace std;
class TaiKhoan {
public:
string ten;
int soDu;
// Default argument 0 for soDu, declared only here
TaiKhoan(string t, int s = 0) : ten(t), soDu(s) {
cout << "Mo tai khoan " << ten << " voi so du " << soDu << "\n";
}
};
int main() {
TaiKhoan a("An"); // soDu uses default value 0
TaiKhoan b("Binh", 500); // soDu is 500
}
Ý chính: tham số mặc định gắn ở khai báo hàm khởi tạo giúp một hàm khởi tạo phục vụ nhiều cách gọi khác nhau.
Bài tập 2: đếm thứ tự hủy
Viết lớp Buoc (bước) lưu một số nguyên thuTu. Hàm khởi tạo in Tao buoc <thuTu>, hàm hủy in Huy buoc <thuTu>. Trong main, tạo ba đối tượng Buoc mang số 1, 2, 3 theo đúng thứ tự đó trong cùng một phạm vi. Hãy dự đoán và giải thích thứ tự các dòng Huy được in ra.
Lời giải chi tiết
Các đối tượng cục bộ trong cùng một phạm vi bị hủy theo thứ tự ngược với thứ tự tạo (vào sau ra trước). Vì ta tạo lần lượt bước 1, 2, 3 nên khi kết thúc phạm vi, trình biên dịch hủy bước 3 trước, rồi bước 2, cuối cùng bước 1.
Do đó phần in Tao đi theo thứ tự 1, 2, 3, còn phần in Huy đi theo thứ tự 3, 2, 1.
#include <iostream>
using namespace std;
class Buoc {
public:
int thuTu;
Buoc(int n) : thuTu(n) { cout << "Tao buoc " << thuTu << "\n"; }
~Buoc() { cout << "Huy buoc " << thuTu << "\n"; }
};
int main() {
Buoc b1(1);
Buoc b2(2);
Buoc b3(3);
// Destruction order is reverse of creation: 3, 2, 1
}
Ý chính: trong một phạm vi, đối tượng tạo sau bị hủy trước, nên thứ tự hủy luôn ngược thứ tự tạo.
Bài tập 3: lớp Timer kiểu RAII
Viết lớp Timer thể hiện một tài nguyên theo phong cách RAII: nhận một tên nhan (string) trong hàm khởi tạo và in Bat dau <nhan>, còn hàm hủy in Ket thuc <nhan>. Mục tiêu là khi một Timer được đặt ở đầu một khối {}, nó tự động in dòng kết thúc ngay khi khối đóng lại, kể cả khi giữa khối có nhiều lệnh khác. Hãy viết main đặt một Timer bao quanh một đoạn công việc giả lập (vài dòng cout) trong một khối lồng, và một Timer khác ở phạm vi ngoài.
Lời giải chi tiết
Ý tưởng RAII là gắn việc mở và đóng một tài nguyên vào vòng đời của đối tượng: mở trong hàm khởi tạo, đóng trong hàm hủy. Nhờ vậy ta không cần nhớ gọi tay lệnh đóng, vì hàm hủy chạy tự động khi đối tượng ra khỏi phạm vi, kể cả khi thoát sớm.
Đặt Timer cong_viec("cong viec") ở đầu khối lồng. Mọi lệnh trong khối chạy xong thì dấu } đóng khối kích hoạt hàm hủy của cong_viec, in Ket thuc cong viec. Timer ngoài cùng (tong) hủy cuối cùng khi main kết thúc.
#include <iostream>
#include <string>
using namespace std;
class Timer {
public:
string nhan;
Timer(string n) : nhan(n) { cout << "Bat dau " << nhan << "\n"; }
~Timer() { cout << "Ket thuc " << nhan << "\n"; }
};
int main() {
Timer tong("tong"); // outer-scope timer
{
Timer cong_viec("cong viec"); // RAII guard for the block
cout << "Lam viec 1\n";
cout << "Lam viec 2\n";
} // cong_viec destroyed here -> prints "Ket thuc cong viec"
cout << "Da xong khoi trong\n";
} // tong destroyed here -> prints "Ket thuc tong"
Ý chính: đặt một đối tượng RAII ở đầu khối khiến phần dọn dẹp tự chạy đúng lúc khối kết thúc, giúp mã an toàn và gọn hơn.
Bài tập 4: thứ tự tạo và hủy của đối tượng thành viên
Viết lớp DongCo in Tao dong co trong hàm khởi tạo và Huy dong co trong hàm hủy. Viết lớp Banh tương tự với Tao banh và Huy banh. Viết lớp Xe có hai thành viên theo đúng thứ tự khai báo: một DongCo rồi một Banh. Thân hàm khởi tạo của Xe in Tao xe, thân hàm hủy in Huy xe. Dự đoán chính xác sáu dòng được in ra khi tạo một Xe trong main, rồi giải thích.
Lời giải chi tiết
Áp dụng sáu bước của vòng đời. Khi tạo Xe, các thành viên được khởi tạo trước theo thứ tự khai báo (DongCo rồi Banh), sau đó mới tới thân hàm khởi tạo của Xe. Khi hủy, thân hàm hủy của Xe chạy trước, rồi các thành viên bị hủy theo thứ tự ngược lại (Banh rồi DongCo).
Vậy thứ tự in là: Tao dong co, Tao banh, Tao xe, Huy xe, Huy banh, Huy dong co. Cách nhớ: bạn lắp linh kiện trước rồi mới ráp thành xe, khi tháo thì gỡ xe ra trước rồi mới tháo linh kiện. Nhờ vậy thân hàm hủy của Xe vẫn dùng được các thành viên, vì chúng chưa bị hủy.
#include <iostream>
using namespace std;
class DongCo {
public:
DongCo() { cout << "Tao dong co\n"; }
~DongCo() { cout << "Huy dong co\n"; }
};
class Banh {
public:
Banh() { cout << "Tao banh\n"; }
~Banh() { cout << "Huy banh\n"; }
};
class Xe {
private:
DongCo dongCo; // declared first -> built first, destroyed last
Banh banh; // declared second -> built second, destroyed first
public:
Xe() { cout << "Tao xe\n"; } // body runs AFTER both members are built
~Xe() { cout << "Huy xe\n"; } // body runs BEFORE any member is destroyed
};
int main() {
Xe x;
// Tao dong co / Tao banh / Tao xe / Huy xe / Huy banh / Huy dong co
}
Ý chính: đối tượng thành viên xây trước thân hàm khởi tạo và hủy sau thân hàm hủy, nên trong cả hai thân hàm bạn luôn dùng được thành viên một cách an toàn.
Tự kiểm tra
Chương trình in ra theo thứ tự nào?
Mo data.txt
Mo log.txt
Dong log.txt
...
Dong data.txt
b sinh sau nhưng hủy trước (vì ra khỏi khối {} trước), a hủy cuối cùng khi
kết thúc main.
Câu hỏi tự kiểm
- 1Các đối tượng cục bộ trong cùng một phạm vi bị hủy theo thứ tự nào?
- 2Với các trường int* p; int n; hàm dựng viết : n(soPhanTu), p(new int[n]) có lỗi gì?
- 3Trường hợp nào BẮT BUỘC dùng danh sách khởi tạo, không thể gán trong thân hàm dựng?
- 4Khi nào nên tự viết hàm hủy?
Constructor chạy khi tạo, destructor chạy khi hủy (ra khỏi phạm vi hoặc
delete). Hủy theo thứ tự ngược tạo. Đây là cơ chế dọn tài nguyên an toàn của C++.