Constructor và destructor trong C++: hàm khởi tạo, hàm hủy và RAII

Constructor và destructor trong C++: hàm khởi tạo, hàm hủy và RAII - Khuê Nguyễn Creator

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, có tham số và ủy quyền tạo các đối tượng Sach, số lượng âm bị đổi thành 0

  • Constructor mặc định Sach(): không tham số, chạy khi viết Sach 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 d vớ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 const và 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;
}

g++ cảnh báo -Wreorder, thuộc tính khởi tạo theo thứ tự khai báo làm số tờ bằng 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;
}

Constructor không explicit cho phép chuyển kiểu ngầm, truyền nhầm mã sách thành mã thẻ

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) {}

Thêm explicit, g++ báo lỗi khi truyền số nguyên thay cho TheThuVien

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;
}

Thứ tự gọi constructor và destructor trong C++: hủy khi ra khỏi khối, đối tượng tạo sau bị hủy trước

  • Đối tượng trên stack bị hủy khi ra khỏi khối { } chứa nó: p2, p3 hủy ngay ở “Het khoi lenh”, còn p1 sống tới cuối main.
  • Trong cùng một khối, đối tượng tạo sau bị hủy trước: p3 (Chi) hủy trước p2 (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
}

Sao chép nông con trỏ thô làm lỗi double free khi hai đối tượng bị hủy

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):

Sơ đồ sao chép nông hai đối tượng chung một vùng nhớ, std::string sao chép riêng từng bản

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/delete trong đó) 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;
}

Quy tắc 0: dùng std::string làm thuộc tính, sao chép đúng mà không cần tự viết hàm

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

  1. Viết class DocGia có 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.
  2. 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.
  3. Trong ví dụ TheThuVien, thêm explicit rồi sửa lời gọi choMuon cho đúng.
  4. Viết lại class TenSach dùng con trỏ thô nhưng tự viết constructor sao chép để sao chép sâu. Kiểm tra bằng -fsanitize=address rằ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é.

Rate this post

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *