Trong bài này chúng ta sẽ học constructor và destructor trong C++: hàm khởi tạo các loại, danh sách khởi tạo, từ khóa explicit, hàm hủy và thứ tự gọi, lỗi sao chép nông, và quy tắc 0/3/5. Ở bài lớp và đối tượng trong C++, chúng ta phải gọi datThongTin() sau khi tạo đối tượng, quên gọi là đối tượng rỗng. Constructor giải quyết đúng vấn đề đó.
Bài 16 trong Serie Lập trình C++ từ A tới Z
Constructor và destructor trong C++ là gì?
- Constructor (hàm khởi tạo) là hàm đặc biệt, tự động chạy khi đối tượng được tạo ra. Tên trùng tên class, không có kiểu trả về.
- Destructor (hàm hủy) tự động chạy khi đối tượng bị hủy. Tên là dấu ngã
~cộng tên class, không có tham số.
Ví dụ đời thường: thuê phòng khách sạn. Lúc nhận phòng (constructor), lễ tân giao chìa khóa, bật điện, ghi sổ. Lúc trả phòng (destructor), thu chìa khóa, tắt điện, xóa sổ. Khách không phải tự làm những việc đó, và không thể quên làm.
Constructor đảm bảo đối tượng luôn hợp lệ ngay từ khi sinh ra, destructor đảm bảo tài nguyên luôn được trả lại khi đối tượng mất đi.
Các loại constructor
#include <iostream>
#include <string>
class Sach
{
public:
Sach() : ten_("Chua dat ten"), soLuong_(0) {} // mac dinh
Sach(const std::string& ten, int soLuong) // co tham so
: ten_(ten), soLuong_(soLuong >= 0 ? soLuong : 0) {}
Sach(const std::string& ten) : Sach(ten, 1) {} // uy quyen cho ham tren
void inThongTin() const { std::cout << ten_ << ": " << soLuong_ << " cuon\n"; }
private:
std::string ten_;
int soLuong_;
};
int main()
{
Sach a;
Sach b("Lap trinh C++", 3);
Sach c("Clean Code");
Sach d{"Cau truc du lieu", -2};
a.inThongTin();
b.inThongTin();
c.inThongTin();
d.inThongTin();
return 0;
}
- Constructor mặc định
Sach(): không tham số, chạy khi viếtSach a;. - Constructor có tham số: nhận dữ liệu ban đầu. Ở đây nó còn kiểm tra số lượng âm, nên
dvới -2 cuốn thành 0. Đối tượng không bao giờ có trạng thái sai. - Constructor ủy quyền (delegating constructor):
Sach(ten) : Sach(ten, 1)gọi lại constructor khác, khỏi viết lặp phần kiểm tra. - Constructor là hàm nên nạp chồng được, giống bài về hàm. Trình biên dịch chọn bản phù hợp theo đối số.
Nếu class không viết constructor nào, C++ tự tạo constructor mặc định. Nhưng chỉ cần viết một constructor có tham số thì constructor mặc định tự động không còn nữa, viết Sach a; sẽ báo lỗi. Đó là lỗi no matching function mà chúng ta đã gặp ở bài tính kế thừa trong C++ khi lớp con quên gọi constructor lớp cha.
Danh sách khởi tạo
Phần : ten_(ten), soLuong_(...) giữa tên constructor và thân hàm gọi là danh sách khởi tạo (member initializer list). Nên dùng nó thay vì gán trong thân hàm, vì 2 lý do:
- Thuộc tính được khởi tạo thẳng với giá trị đúng, thay vì tạo rỗng rồi gán lại.
- Thuộc tính
constvà thuộc tính kiểu tham chiếu chỉ khởi tạo được trong danh sách khởi tạo, không gán được trong thân hàm.
Thứ tự khởi tạo theo thứ tự khai báo
Một cái bẫy ít người biết: thuộc tính được khởi tạo theo thứ tự khai báo trong class, không phải thứ tự viết trong danh sách khởi tạo.
#include <iostream>
#include <string>
class Sach
{
public:
Sach(const std::string& ten, int soTrang)
: soTrang_(soTrang), soTo_(soTrang_ / 2), ten_(ten) {}
void inThongTin() const
{
std::cout << ten_ << ": " << soTrang_ << " trang, " << soTo_ << " to\n";
}
private:
std::string ten_;
int soTo_; // khai bao truoc soTrang_
int soTrang_;
};
int main()
{
Sach s("Lap trinh C++", 356);
s.inThongTin();
return 0;
}
Sách 356 trang mà có 0 tờ. Danh sách khởi tạo viết soTrang_ trước, nhưng soTo_ khai báo trước trong class nên được khởi tạo trước, lúc đó soTrang_ chưa có giá trị. soTo_ đọc phải giá trị rác.
Lưu ý: Luôn viết danh sách khởi tạo đúng thứ tự khai báo thuộc tính, và không để thuộc tính này khởi tạo dựa vào thuộc tính khác nếu tránh được (ở đây dùng tham số soTrang / 2 thay vì soTrang_ / 2). g++ cảnh báo -Wreorder khi bật -Wall.
Từ khóa explicit
Constructor có đúng một tham số còn được C++ dùng để tự động chuyển kiểu. Điều này nhiều khi gây lỗi rất khó phát hiện:
#include <iostream>
class TheThuVien
{
public:
TheThuVien(int maThe) : maThe_(maThe) {}
int maThe() const { return maThe_; }
private:
int maThe_;
};
void choMuon(const TheThuVien& the, int maSach)
{
std::cout << "The " << the.maThe() << " muon sach " << maSach << '\n';
}
int main()
{
int maThe = 1024, maSach = 5001;
choMuon(maSach, maThe); // nham thu tu tham so, van bien dich duoc
return 0;
}
Truyền nhầm thứ tự 2 tham số mà g++ vẫn biên dịch: nó lấy số maSach 5001 tự tạo ra một TheThuVien tạm, và sách 1024 bị cho thẻ 5001 mượn. Thêm explicit trước constructor là chặn được:
explicit TheThuVien(int maThe) : maThe_(maThe) {}
Giờ muốn tạo thẻ phải viết rõ TheThuVien(1024), truyền nhầm số nguyên là lỗi biên dịch ngay. Quy tắc chung: constructor một tham số thì thêm explicit, trừ khi các bạn thật sự muốn cho chuyển kiểu tự động (như std::string nhận được chuỗi "abc").
Destructor và thứ tự hủy
#include <iostream>
#include <string>
class PhieuMuon
{
public:
PhieuMuon(const std::string& docGia) : docGia_(docGia)
{
std::cout << " Mo phieu muon cho " << docGia_ << '\n';
}
~PhieuMuon()
{
std::cout << " Dong phieu muon cua " << docGia_ << '\n';
}
private:
std::string docGia_;
};
int main()
{
std::cout << "Bat dau main\n";
PhieuMuon p1("An");
{
PhieuMuon p2("Binh");
PhieuMuon p3("Chi");
std::cout << "Het khoi lenh\n";
}
std::cout << "Ket thuc main\n";
return 0;
}
- Đối tượng trên stack bị hủy khi ra khỏi khối
{ }chứa nó:p2,p3hủy ngay ở “Het khoi lenh”, cònp1sống tới cuốimain. - Trong cùng một khối, đối tượng tạo sau bị hủy trước:
p3(Chi) hủy trướcp2(Binh). Giống chồng đĩa, đặt vào sau thì lấy ra trước.
Đây là kỹ thuật RAII (Resource Acquisition Is Initialization): mở tài nguyên trong constructor, đóng trong destructor. Đối tượng ra khỏi phạm vi, kể cả do return sớm hay ngoại lệ, thì tài nguyên luôn được đóng. std::string, std::vector, std::unique_ptr, file std::fstream đều hoạt động theo cách này.
Constructor sao chép và lỗi sao chép nông
Viết TenSach b = a; thì C++ gọi constructor sao chép (copy constructor). Nếu không tự viết, C++ tạo sẵn một bản sao chép từng thuộc tính. Với thuộc tính là con trỏ thô, nó chỉ chép địa chỉ, không chép dữ liệu:
#include <cstring>
#include <iostream>
class TenSach
{
public:
TenSach(const char* ten)
{
duLieu_ = new char[std::strlen(ten) + 1];
std::strcpy(duLieu_, ten);
}
~TenSach() { delete[] duLieu_; }
const char* c_str() const { return duLieu_; }
private:
char* duLieu_;
};
int main()
{
TenSach a("Lap trinh C++");
TenSach b = a; // sao chep mac dinh: chep con tro, khong chep du lieu
std::cout << a.c_str() << " / " << b.c_str() << '\n';
return 0; // a va b cung delete[] mot vung nho
}
Chương trình in đúng, rồi crash lúc kết thúc với lỗi double free. a và b cùng trỏ vào một vùng nhớ, nên khi cả 2 bị hủy thì vùng nhớ đó bị delete[] hai lần. Đây gọi là sao chép nông (shallow copy):
Quy tắc 0, 3 và 5
Có 2 cách sửa lỗi trên:
- Quy tắc 3/5: class tự quản lý tài nguyên (có
new/deletetrong đó) thì phải tự viết đủ bộ: destructor, constructor sao chép, toán tử gán sao chép (quy tắc 3), cộng thêm constructor di chuyển và toán tử gán di chuyển (quy tắc 5). Viết sót một hàm là có lỗi. - Quy tắc 0: đừng để class tự quản lý tài nguyên. Dùng thuộc tính kiểu
std::string,std::vector,std::unique_ptr, vì chúng đã tự biết sao chép và giải phóng đúng. Khi đó không cần viết hàm nào trong 5 hàm trên.
#include <iostream>
#include <string>
class TenSach
{
public:
TenSach(const std::string& ten) : duLieu_(ten) {}
const std::string& str() const { return duLieu_; }
private:
std::string duLieu_; // std::string tu biet sao chep va giai phong
};
int main()
{
TenSach a("Lap trinh C++");
TenSach b = a; // sao chep that su
std::cout << a.str() << " / " << b.str() << '\n';
return 0;
}
Chỉ đổi char* thành std::string mà lỗi biến mất, class còn ngắn hơn. Trong code C++ hiện đại, quy tắc 0 là lựa chọn mặc định. Quy tắc 3/5 chỉ cần khi các bạn viết chính những lớp quản lý tài nguyên cấp thấp, như tự viết một kiểu vector riêng.
Lưu ý: Thấy class có thuộc tính con trỏ thô và có delete trong destructor mà không có constructor sao chép, thì gần như chắc chắn có lỗi sao chép nông. Hoặc viết đủ quy tắc 3/5, hoặc đổi sang std::unique_ptr / std::vector để trình biên dịch tự xử lý.
Bài tập
- Viết class
DocGiacó constructor nhận họ tên và mã thẻ, mã thẻ âm thì đổi thành 0. Thêm constructor ủy quyền chỉ nhận họ tên, mã thẻ mặc định là 0. - Thêm destructor in ra màn hình cho class ở bài 1. Tạo 3 đối tượng trong
main, một đối tượng trong khối{ }riêng, và đoán trước thứ tự in ra. - Trong ví dụ
TheThuVien, thêmexplicitrồi sửa lời gọichoMuoncho đúng. - Viết lại class
TenSachdùng con trỏ thô nhưng tự viết constructor sao chép để sao chép sâu. Kiểm tra bằng-fsanitize=addressrằng không còn lỗi double free.
Lời giải sẽ có trong bài tổng hợp bài tập OOP C++ nhé.
Kết
Constructor và destructor trong C++ đảm bảo đối tượng hợp lệ ngay từ lúc sinh ra và tài nguyên luôn được trả lại khi đối tượng mất đi, đó là nền tảng của RAII. Những điểm cần nhớ: dùng danh sách khởi tạo và viết đúng thứ tự khai báo, thêm explicit cho constructor một tham số, và ưu tiên quy tắc 0 để không phải tự viết sao chép.
Muốn tra cứu chi tiết về constructor, destructor và quy tắc 0, 3, 5, các bạn xem thêm tài liệu chuẩn trên cppreference.
Bài tiếp theo chúng ta sẽ học tính đóng gói trong C++, nơi constructor kết hợp với private để giữ dữ liệu luôn đúng. Nếu thấy bài viết có ích, hãy chia sẻ cho bạn bè đang học C++ nhé.









