Menu

Constructor trong C++: khởi tạo đối tượng đúng cách

Hàm khởi tạo là hàm thành viên đặc biệt chạy khi đối tượng được tạo ra. Tìm hiểu hàm khởi tạo mặc định, có tham số và sao chép, danh sách khởi tạo thành viên, và cách tránh để đối tượng ở trạng thái khởi tạo dở dang.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Hàm khởi tạo là gì

Ở trang trước bạn đã định nghĩa lớp và thêm biến thành viên cho chúng. Nhưng các thành viên của một đối tượng vừa tạo sẽ chứa bất kỳ giá trị rác nào còn sót trong vùng nhớ đó nếu bạn không gán. Hàm khởi tạo (constructor) giải quyết chuyện này: đó là một hàm thành viên đặc biệt tự động chạy ngay khi đối tượng được tạo ra, và nhiệm vụ duy nhất của nó là để đối tượng ở trạng thái hợp lệ, được khởi tạo đầy đủ.

Hàm khởi tạo trùng tên với lớp và không có kiểu trả về, kể cả void. Bạn không bao giờ gọi trực tiếp; trình biên dịch tự gọi nó mỗi khi có một đối tượng ra đời.

Counter() không có tham số được gọi là hàm khởi tạo mặc định (default constructor): đây là hàm được dùng khi bạn tạo đối tượng mà không truyền đối số nào.

Hàm khởi tạo có tham số

Hàm khởi tạo không nhận đối số thì vẫn ổn, nhưng thường bạn muốn tạo đối tượng với những giá trị cụ thể. Hàm khởi tạo có tham số nhận đối số và dùng chúng để khởi tạo các thành viên.

Một lớp có thể có nhiều hàm khởi tạo, miễn là danh sách tham số khác nhau: đây chính là nạp chồng hàm thông thường áp dụng cho hàm khởi tạo. Ở đây Point có thể được tạo có hoặc không có tọa độ:

Một cái bẫy hay gặp: Point p(); không tạo đối tượng nào cả, vì trình biên dịch hiểu nó là khai báo một hàm tên p trả về Point. Để gọi hàm khởi tạo mặc định, hãy viết Point p; (không có ngoặc tròn) hoặc Point p{}; với ngoặc nhọn.

Danh sách khởi tạo thành viên

Đến giờ các ví dụ đều gán giá trị cho thành viên bên trong thân hàm khởi tạo. Cách đó chạy được với kiểu đơn giản, nhưng không phải công cụ đúng. Khi thân hàm bắt đầu chạy, mọi thành viên đã được khởi tạo mặc định rồi; thân hàm lại bỏ giá trị đó đi và gán đè lên. Danh sách khởi tạo thành viên (member initializer list) khởi tạo trực tiếp từng thành viên ngay trước thân hàm, chỉ trong một bước.

Cú pháp là một dấu hai chấm sau danh sách tham số, rồi đến các cặp member(value):

Với thành viên kiểu string, cách này còn tránh được việc tạo một chuỗi rỗng rồi mới gán: danh sách khởi tạo dựng chuỗi đúng ngay lần đầu.

Danh sách khởi tạo không chỉ là chuyện tối ưu; nó bắt buộc trong ba trường hợp, vì thân hàm chỉ gán được chứ không khởi tạo được:

  • Thành viên const: bạn không thể gán cho một const sau khi nó đã tồn tại.
  • Thành viên tham chiếu: tham chiếu phải được gắn ngay khi ra đời.
  • Thành viên có kiểu không có hàm khởi tạo mặc định.
class Sensor {
    const int id;        // const member
    int& slot;           // reference member

public:
    Sensor(int sensorId, int& s) : id(sensorId), slot(s) {}
    // Trying to set id or slot in the body would not compile.
};

Một chi tiết tinh tế cần biết: các thành viên được khởi tạo theo thứ tự khai báo trong lớp, không theo thứ tự bạn liệt kê trong danh sách khởi tạo. Nếu giá trị khởi tạo của một thành viên đọc thành viên khác, thứ tự khai báo mới là thứ quyết định. Để hai thứ tự lệch nhau là nguồn lỗi kinh điển khiến bạn dùng một giá trị chưa được khởi tạo.

Đối số mặc định và hàm khởi tạo ủy quyền

Không phải lúc nào bạn cũng cần các bản nạp chồng riêng. Đối số mặc định cho phép một hàm khởi tạo duy nhất bao được nhiều trường hợp: bỏ qua một đối số thì giá trị mặc định sẽ được dùng thay:

Cẩn thận khi kết hợp một hàm khởi tạo có tham số mặc định với một hàm khởi tạo mặc định Point() riêng: trình biên dịch không biết gọi hàm nào cho Point p; và sẽ báo lỗi mơ hồ (ambiguity). Chỉ chọn một cách.

Khi bạn thật sự có nhiều hàm khởi tạo dùng chung phần thiết lập, hàm khởi tạo ủy quyền (delegating constructor, C++11) cho phép một hàm khởi tạo gọi hàm khác thay vì lặp lại logic. Bạn "ủy quyền" bằng cách đặt hàm khởi tạo kia vào danh sách khởi tạo:

Hàm khởi tạo sao chép

Khi bạn tạo một đối tượng bằng cách sao chép đối tượng khác, như truyền theo giá trị, trả về nó, hay viết Foo b = a;, thì hàm khởi tạo sao chép (copy constructor) sẽ chạy. Chữ ký của nó nhận một tham chiếu const tới cùng kiểu:

ClassName(const ClassName& other);

Nếu bạn không viết, trình biên dịch tự sinh một hàm khởi tạo sao chép mặc định, sao chép từng thành viên. Với những lớp chỉ chứa giá trị (int, chuỗi, vector), như vậy là hoàn toàn đúng và bạn không nên tự viết.

Cái bẫy lớn nằm ở chương sau về bộ nhớ: nếu lớp của bạn sở hữu một con trỏ thô trỏ tới vùng nhớ heap, hàm khởi tạo sao chép mặc định sẽ sao chép con trỏ chứ không sao chép dữ liệu. Kết quả là hai đối tượng cùng trỏ vào một vùng nhớ, và cả hai đều sẽ cố giải phóng nó. Đó là bug double free. Quy tắc chung là Rule of Three/Five: nếu bạn tự viết hàm hủy, gần như chắc chắn bạn cũng cần tự viết hàm khởi tạo sao chép (và toán tử gán sao chép). Trong C++ hiện đại, cách sửa gọn hơn là giữ dữ liệu trong std::vector hoặc smart pointer để bản sao do trình biên dịch sinh ra chạy đúng luôn.

Cũng lưu ý rằng nhận tham số bằng tham chiếu là bắt buộc chứ không phải tùy chọn: một hàm khởi tạo sao chép nhận đối số theo giá trị sẽ phải sao chép đối số, tức là gọi chính nó, dẫn tới đệ quy vô hạn và thậm chí không biên dịch được.

Tiếp theo: hàm hủy

Hàm khởi tạo dựng đối tượng lên; hàm hủy (destructor) dọn nó đi. Khi một đối tượng ra khỏi phạm vi hoặc bị xóa, hàm hủy của nó tự động chạy, nơi lý tưởng để giải phóng tệp, kết nối mạng hay vùng nhớ heap mà đối tượng đang giữ. Trang tiếp theo trình bày cách hàm hủy hoạt động, chính xác khi nào nó được gọi, và cách nó kết hợp với hàm khởi tạo để tạo nên mẫu RAII mạnh mẽ của C++.

Câu hỏi thường gặp

Hàm khởi tạo trong C++ là gì?

Hàm khởi tạo (constructor) là một hàm thành viên đặc biệt, trùng tên với lớp và không có kiểu trả về. Nó tự động chạy khi đối tượng được tạo, và nhiệm vụ của nó là đưa đối tượng về trạng thái hợp lệ, được khởi tạo đầy đủ trước khi bất kỳ đoạn code nào khác dùng đến.

Hàm khởi tạo mặc định và hàm khởi tạo có tham số khác nhau thế nào?

Hàm khởi tạo mặc định không nhận đối số và được dùng khi bạn tạo đối tượng mà không truyền giá trị (Point p;). Hàm khởi tạo có tham số nhận đối số để nơi gọi khởi tạo đối tượng với giá trị cụ thể (Point p(3, 4);). Một lớp có thể có cả hai, vì hàm khởi tạo được nạp chồng theo danh sách tham số.

Vì sao nên dùng danh sách khởi tạo thành viên trong C++?

Danh sách khởi tạo thành viên (: name(n), age(a)) khởi tạo trực tiếp các thành viên trước khi thân hàm khởi tạo chạy. Nó bắt buộc với thành viên const, tham chiếu và thành viên không có hàm khởi tạo mặc định, đồng thời tránh được việc lãng phí khi tạo giá trị mặc định rồi mới gán đè trong thân hàm.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu