Chuyển tới nội dung chính

Chương 2: Lớp, Đối tượng và Tính đóng gói

2.1 Khái niệm về Lớp (Class) và Đối tượng (Object)

Lớp đối tượng là đơn vị đóng gói cơ bản của C++, cung cấp cơ chế tạo ra một đối tượng. Có thể xem rằng lớp là một khuôn mẫu và đối tượng là một thực thể được thể hiện dựa trên khuôn mẫu đó. Hay nói cách khác, lớp là một mô tả trừu tượng của một nhóm các đối tượng có cùng bản chất, ngược lại, mỗi đối tượng là một thể hiện cụ thể cho những mô tả trừu tượng đó.

Một lớp đối tượng bao gồm 2 thành phần chính:

  • Thành phần dữ liệu (data member), hay còn được gọi là thuộc tính (attribute).
  • Hàm thành phần (member function), còn có tên gọi khác là phương thức (method), là các hành động mà đối tượng của lớp có thể thực hiện.

Ví dụ, trong C++ ta có thể tự định nghĩa một lớp đối tượng có tên là HocSinh với các thuộc tính như mã số sinh viên, họ tên, điểm trung bình và các phương thức như đi, đứng, ngồi, học tập. Sau đó, ta có thể tạo ra các đối tượng khác nhau của lớp HocSinh, chẳng hạn như hs1, hs2, hs3, … với các giá trị khác nhau cho các thuộc tính và các hành vi khác nhau cho các phương thức.

Class_Object

Hình 2: Minh họa cho lớp và đối tượng

2.2 Khai báo và định nghĩa một Lớp đối tượng mới

Trong C++, để định nghĩa một lớp ta bắt đầu bằng từ khóa class, tiếp theo đó là tên của lớp và phần thân lớp được bao bởi cặp dấu tạo thành một phạm vi (scope). Việc định nghĩa được kết thúc bằng dấu chấm phẩy.

class TenLop {
// Thành phần dữ liệu (thuộc tính).
// Hàm thành phần (phương thức).
};

Cụ thể hơn, ta sẽ định nghĩa một lớp đối tượng có tên là HocSinh như sau:

class HocSinh {
private:
int mssv;
string hoTen;
double diemToan;
double diemVan;
double diemTB;
void XuLy();
public:
void Nhap();
void Xuat();
};

Ở ví dụ này, lớp đối tượng HocSinh có 5 thuộc tính bao gồm mssv, hoTen, diemToan, diemVan, diemTB cùng với 3 phương thức là XuLy, Nhap, Xuat. Các thuộc tính và phương thức của một lớp đối tượng được khai báo giống như khi ta khai báo biến và hàm trong một chương trình.

Ngoài ra trong ví dụ trên ta còn sử dụng các từ khóa chỉ định phạm vi truy cập là privatepublic, phần nội dung này sẽ được nói ở các chương sau.

Khai báo một đối tượng:

Việc khai báo đối tượng của một lớp được thực hiện tương tự khi ta khai báo một biến bình thường.

Ví dụ 1: HocSinh hs1;

Trong ví dụ này, ta nói hs1 là một đối tượng thuộc về lớp đối tượng HocSinh. Lớp HocSinh trong trường hợp này giống như một kiểu dữ liệu (do người lập trình tự định nghĩa) nên cũng có thể nói hs1 là một biến có kiểu dữ liệu HocSinh.

Ví dụ 2: HocSinh hs1,hs2,hs3;

Ở ví dụ này, ta gọi hs1, hs2, hs3 là ba đối tượng thuộc về lớp 1 đối tượng HocSinh. Hay nói cách khác, lớp đối tượng HocSinh có ba sự thể hiện khác nhau. Lúc này ba biến hs1, hs2, hs3 được cấp phát cho ba vùng nhớ riêng biệt và mỗi biến có thể giữ các giá trị khác nhau tương ứng với từng thuộc tính.

2.3 Hàm thành phần - Phương thức (Member function - Method)

2.3.1 Khái niệm

  • Phương thức là các khả năng, thao tác mà một đối tượng thuộc về lớp có thể thực hiện.

  • Về cơ bản, phương thức cũng không khác so với một hàm bình thường. Nó có thể có hoặc không có tham số và giá trị trả về.

2.3.2 Cách gọi phương thức

Để gọi một phương thức, ta sử dụng toán tử chấm (dot operator) trên một đối tượng của lớp hoặc toán tử mũi tên (arrow operator) lên một con trỏ giữ địa chỉ của đối tượng thuộc lớp đó, ví dụ:

HocSinh hs;
hs.Nhap(); // đối tượng hs gọi thực hiện phương thức Nhap
HocSinh* pHs = &hs;
pHs->Nhap(); // đối tượng mà pHs giữ địa chỉ gọi thực hiện phương thức Nhap

Nói chung, toán tử chấm và toán tử mũi tên có thể được dùng để truy cập đến một thành viên bất kì của đối tượng khi đang trong phạm vi lớp, hoặc truy cập đến các thành viên public nếu ở ngoài phạm vi lớp (sẽ được nói kĩ hơn ở chương sau).

2.3.3. Định nghĩa phương thức

Trong ví dụ về lớp HocSinh ở trên ta chỉ mới khai báo các phương thức mà chưa định nghĩa chúng. Các phương thức của một lớp đối tượng phải được khai báo bên trong thân lớp, tuy nhiên việc định nghĩa có thể được thực hiện ở cả bên trong hoặc bên ngoài thân lớp.

Ta định nghĩa một phương thức bên trong thân lớp tương tự như khi định nghĩa một hàm bình thường, còn khi muốn định nghĩa một phương thức bên ngoài lớp, ta phải sử dụng toán tử phạm vi (scope operator – dấu ::) để chương trình biết ta đang định nghĩa phương thức của lớp nào. Lúc này phần thân phương thức được xem như đang nằm trong phạm vi của lớp đó. Cú pháp:

KDL_trả_về Tên_lớp::Tên_phương_thức(<Tham_số>) {
// Thân phương thức
}

Ví dụ, phương thức Xuly và Nhap sẽ được định nghĩa bên ngoài lớp HocSinh như sau:

void HocSinh::XuLy() {
diemTB = (diemToan + diemVan) / 2;
}
void HocSinh::Nhap() {
cout << "Nhap MSSV: ";
cin >> mssv;
cout << "Nhap ho ten: ";
cin.ignore();
getline(cin, hoTen);
cout << "Nhap diem toan: ";
cin >> diemToan;
cout << "Nhap diem van: ";
cin >> diemVan;
XuLy();
}

Tới đây có lẽ nhiều bạn đọc sẽ thắc mắc rằng các biến mssv, hoTen, diemToan, diemVan, diemTB ở đâu ra trong khi các phương thức ở trên không có tham số đầu vào. Để giải thích điều này ta sẽ tìm hiểu về khái niệm con trỏ this.

2.3.4. Giới thiệu về con trỏ “this”

Trước hết, hãy cùng ôn lại một số kiến thức về con trỏ trong C++:

  • Ghi nhớ: Miền giá trị của một biến con trỏ là địa chỉ ô nhớ
  • Trong câu lệnh HocSinh* p = &hs; ta nói p là một biến con trỏ kiểu HocSinh, địa chỉ của đối tượng hs thuộc lớp HocSinh được gán cho biến con trỏ p.
  • Con trỏ hằng (constant pointer) là một con trỏ mà địa chỉ nó đang giữ không thể bị thay đổi, ví dụ:
HocSinh* const p = &hs

Trong ví dụ này, p là một con trỏ hằng, p sẽ giữ địa chỉ của đối tượng hs trong suốt quá trình tồn tại của mình, và ta không thể thay đổi giá trị của p (là địa chỉ ô nhớ).

Trở lại vấn đề, con trỏ this là một con trỏ hằng được chương trình tự định nghĩa bên trong một phương thức, nó sẽ giữ và chỉ có thể giữ địa chỉ của đối tượng đang gọi thực hiện phương thức đó. Vì thế, con trỏ this luôn có kiểu trùng với kiểu của lớp đối tượng mà nó thuộc về.

Nhìn lại một ví dụ về việc gọi phương thức:

hs.Nhap(); // đối tượng hs gọi thực hiện phương thức Nhap

Khi dòng lệnh trên được thực hiện, chương trình sẽ tự động gán địa chỉ của đối tượng hs vào biến con trỏ this (được định nghĩa ngầm bên trong phương thức Nhap()). Sau đó, ta có thể dùng con trỏ này để truy cập đến các thuộc tính của đối tượng hs, cũng như dùng nó để gọi các phương thức khác, ví dụ:

void HocSinh::Nhap() {
// ...
cin >> this->mssv; // nhập MSSV của hs
// ...
this->XuLy(); // gọi Xuly() trên đối tượng hs
}

Tuy nhiên để cho gọn, chương trình cho phép ta truy cập trực tiếp đến các thành viên của đối tượng đang gọi thực hiện phương thức. Bất kì tên của thành viên nào được ghi ra mà không nói gì thêm thì thành viên đó sẽ xem như là được truy cập thông qua con trỏ this.

2.4 Trừu tượng hóa dữ liệu (Data abstraction) và Đóng gói (Encapsulation)

Một trong những ý tưởng cơ bản đằng sau việc xây dựng và thiết kế một Lớp đối tượng chính là Trừu tượng hóa dữ liệu (Data abstraction)Đóng gói (Encapsulation).

Trừu tượng hóa dữ liệu là một kỹ thuật lập trình và thiết kế dựa trên sự tách biệt của Giao diện (Interface)Thực thi (Implementation). Giao diện của một Lớp đối tượng là các hoạt động mà người dùng của một Lớp có thể thao tác trên các đối tượng của Lớp đó. Phần Thực thi bao gồm các dữ liệu thành viên (thuộc tính), phần định nghĩa của các phương thức.

Đóng gói chính là quá trình ẩn đi phần Thực thi khỏi người dùng bên ngoài và giới hạn quyền truy cập vào nó. Người dùng của một Lớp chỉ có thể sử dụng Giao diện mà không có quyền truy cập vào phần Thực thi.

Một Lớp đối tượng được áp dụng đặc tính Trừu tượng hóa dữ liệu và Đóng gói sẽ tạo thành một kiểu dữ liệu trừu tượng mô phỏng lại một khái niệm bên ngoài thế giới thực. Những lập trình viên sử dụng Lớp chỉ cần biết một cách trừu tượng rằng đối tượng của Lớp có thể thực hiện các hoạt động gì mà không cần hiểu cách thức thực hiện bên trong.

2.5 Phạm vi truy xuất

Trong C++, chúng ta thực hiện việc đóng gói bằng cách chỉ định phạm vi truy xuất (access specifiers):

  • Những thành phần được khai báo sau từ khóa public có thể được truy cập ở tất cả các phần của chương trình. Các thành phần public tạo nên giao diện của một Lớp.

  • Những thành phần được khai báo sau từ khóa private chỉ có thể được truy cập bên trong phạm vi của lớp, bởi các hàm thành viên (phương thức) của một lớp và không thể được truy cập từ bên ngoài lớp. Phần private ẩn đi (đóng gói) phần thực thi.

Ngoài ra còn một loại phạm vi truy xuất nữa là protected sẽ được nói ở phần kế thừa.

Nhìn lại ví dụ về lớp HocSinh ở phần trước:

class HocSinh {
private:
int mssv;
string hoTen;
double diemToan;
double diemVan;
double diemTB;
void XuLy();
public:
void Nhap();
void Xuat();
};

Các phương thức NhapXuat được khai báo sau từ khóa public tạo nên phần giao diện. Các thuộc tính như mssv,hoTen,diemToan,diemVan,diemTB và phương thức XuLy được khai báo sau từ khóa private, cùng với phần định nghĩa của các phương thức tạo nên phần thực thi của lớp HocSinh.

Ngoài ra, các thành viên đã được đóng gói (gán nhãn private) thì sẽ không thể truy cập được từ bên ngoài lớp. Trong ví dụ trên, ta chỉ có thể gọi phương thức NhapXuat mà không thể gọi phương thức Xuly hay truy cập các thuộc tính của lớp HocSinh khi ở ngoài phạm vi lớp:

// ... khai báo lớp, thư viện, không gian tên
int main() {
HocSinh hs;
hs.Nhap(); // Đúng
hs.Xuat(); // Đúng
hs.hoTen; // Sai
hs.XuLy(); // Sai
return 0;
}

Lưu ý: Sự khác biệt giữa từ khóa structclass:

Trong một lớp có thể không có hoặc có nhiều nhãn privatepublic, mỗi nhãn này có phạm vi ảnh hưởng cho đến khi gặp một nhãn kế tiếp hoặc hết khai báo lớp.

Nếu khai báo một Lớp sử dụng từ khóa struct, những thành phần được khai báo trước nhãn truy cập đầu tiên sẽ được mặc định là public, nếu sử dụng từ khóa class, những thành phần đó sẽ mặc định là private:

class HocSinh {
int mssv;
string hoTen; // MSSV và HoTen được xem như private
private:
double diemToan;
double diemVan;
// ...
// những thành phần còn lại như ví dụ ở trên
};
struct HocSinh {
int mssv;
string hoTen; // MSSV và HoTen được xem như public
private:
double diemToan;
double diemVan;
// ...
// những thành phần còn lại như ví dụ ở trên
};

2.6 Phương thức truy vấn và cập nhật

Vì các thuộc tính của một đối tượng được đóng gói thì không thể được truy cập từ bên ngoài, ta phải định nghĩa các phương thức dùng để truy cập và thay đổi dữ liệu của đối tượng đó:

  • Phương thức truy vấn được sử dụng để lấy giá trị của một thuộc tính private
  • Phương thức cập nhật dùng để thay đổi giá trị của một thuộc tính private

Trong ví dụ về lớp HocSinh, ta sẽ định nghĩa một phương thức truy vấn có tên là getDiemToan và một phương thức cập nhật có tên là setDiemToan như sau (ở đây xem như đã có khai báo cho hai hàm này bên trong thân lớp):

double HocSinh::getDiemToan() {
return diemToan;
}
void HocSinh::setDiemToan(double toan) {
if (toan > 10 || toan < 0) {
cout << "Diem toan khong duoc lon hon 10 hoac be hon 0" << endl;
return;
}
this->diemToan = toan;
this->XuLy(); // tính lại diemTB
}

Phương thức cập nhật giúp ta thay đổi dữ liệu bên trong của của một đối tượng mà vẫn đảm bảo được tính đóng gói. Ở ví dụ trên, trong hàm setDiemToan, trước khi thực hiện việc cập nhật điểm toán, ta kiểm tra xem đối số được đưa vào có thỏa mãn điều kiện hay không, nếu không thỏa mãn, chương trình sẽ báo lỗi và không thực hiện thay đổi nào.

Lưu ý: Các lợi ích khi áp dụng đặc tính Đóng gói:

  • Giúp bảo vệ dữ liệu bên trong tránh khỏi các sai sót không đáng có từ người dùng (như ví dụ về phương thức cập nhật ở trên).
  • Giúp thay đổi phần thực thi của lớp một cách linh hoạt (tức là thay đổi cách tổ chức dữ liệu, chi tiết thực hiện các phương thức). Miễn là phần giao diện không bị thay đổi thì những đoạn code sử dụng lớp sẽ không bị ảnh hưởng, do đó không làm đổ vỡ kiến trúc hệ thống.

2.7. Phương thức thiết lập (Constructor)

a) Mục tiêu

  • Các phương thức thiết lập của một lớp đối tượng có nhiệm vụ thiết lập thông tin ban đầu cho các đối tượng thuộc về lớp sau khi đối tượng được khai báo.

b) Các đặc diểm:

  • Phương thức thiết lập là một hàm thành viên đặc biệt có tên trùng với tên lớp.
  • Không có giá trị trả về.
  • Được tự động gọi thực hiện ngay khi đối tượng được khai báo.
  • Có thể có nhiều phương thức thiết lập trong 1 lớp.
  • Trong một quá trình sống của đối tượng thì chỉ có 1 lần duy nhất một phương thức thiết lập được gọi thực hiện mà thôi đó là khi đối tượng ra đời.

c) Phân loại phương thức thiết lập.

  • Phương thức thiết lập mặc định (default constructor).
  • Phương thức thiết lập nhận tham số đầu vào (parameterized constructor).
  • Phương thức thiết lập sao chép (copy constructor).

2.7.1. Phương thức thiết lập mặc định (default constructor)

Trong các ví dụ về lớp HocSinh ở trên, mặc dù chúng ta chưa định nghĩa bất kì phương thức thiết lập nào cho lớp, chương trình sử dụng lớp HocSinh vẫn có thể chạy một cách bình thường. Ví dụ, trong hàm main, khi chúng ta khai báo:

HocSinh hs;

Biến hs lúc này sẽ được khởi tạo mặc định bằng phương thức thiết lập mặc định. Phương thức thiết lập mặc định không có tham số đầu vào và được chương trình tự định nghĩa khi người thiết kế lớp không định nghĩa bất kì phương thức thiết lập nào cho lớp.

Khi thực hiện lệnh hs.Xuat(), ta sẽ nhận được kết quả như sau:

-858993460

-9.25596e+61
-9.25596e+61
-9.25596e+61

Lúc này phương thức thiết lập mặc định do chương trình tự định nghĩa cho các thuộc tính mssv,diemToan,diemVan,diemTB nhận giá trị ngẫu nhiên, còn hoTen nhận giá trị là một chuỗi rỗng.

Ngoài ra, chúng ta có thể tự định nghĩa một phương thức thiết lập mặc định của riêng mình bên trong thân của lớp HocSinh như sau:

HocSinh() {
cout << "Default constructor of HocSinh has been called" << endl;
mssv = 0;
hoTen = "Unknown";
diemToan = 0.0;
diemVan = 0.0;
diemTB = 0.0;
}

Để ý ở đây, tên hàm là HocSinh trùng với tên lớp, không có giá trị trả về, và vì là phương thức thiết lập mặc định nên sẽ không có tham số đầu vào.

Khi đó, nếu chúng ta khai báo

HocSinh hs;

thì chương trình sẽ dùng phương thức thiết lập mặc định do chúng ta vừa định nghĩa để khởi tạo cho đối tượng hs, và đây là kết quả khi xuất hs ra màn hình:

Default constructor of HocSinh has been called
0
Unknown
0
0
0

Lưu ý:

  • Nếu chúng ta có định nghĩa các phương thức thiết lập khác thì chương trình sẽ không tự định nghĩa phương thức thiết lập mặc định cho chúng ta, do đó ta phải tự định nghĩa một phiên bản của riêng mình như ví dụ ở trên.
  • Phương thức thiết lập mặc định còn được chương trình tự gọi khi ta khai báo một mảng các đối tượng của một lớp như các cách sau:
HocSinh arr[5]; 
HocSinh* arr = new HocSinh[5];

Khi chạy chương trình, ta thấy câu thông báo được xuất ra 5 lần, chứng tỏ phương thức thiết lập mặc định đã được gọi trên 5 phần tử của mảng arr:

Default constructor of HocSinh has been called
Default constructor of HocSinh has been called
Default constructor of HocSinh has been called
Default constructor of HocSinh has been called
Default constructor of HocSinh has been called

2.7.2. Phương thức thiết lập nhận tham số đầu vào (parameterized constructors)

Là các phương thức thiết lập sử dụng các đối số được truyền vào nó để khởi tạo dữ liệu cho các thuộc tính của đối tượng.

Tiếp tục với ví dụ về lớp HocSinh, ta sẽ định nghĩa bên trong thân lớp một phương thức thiết lập nhận 4 tham số đầu vào lần lượt là mã số sinh viên, họ tên, điểm toán, điểm văn như sau:

HocSinh(int id, string name, int toan, int van) {
mssv = id;
hoTen = name;
diemToan = toan;
diemVan = van;
XuLy(); // tính diemTB
}

Để gọi phương thức thiết lập vừa được định nghĩa ở trên, ta sẽ khai báo đối tượng hs như dưới đây:

HocSinh hs(22520971, "Le Duy Nguyen", 8, 7);

Trong cùng một chương trình đó, ta cũng có thể định nghĩa thêm nhiều phương thức thiết lập khác miễn là chúng có danh sách tham số đầu vào khác nhau, ví dụ ở đây chúng sẽ định nghĩa thêm một phương thức thiết lập nhận 2 tham số đầu vào là mã số sinh viên và họ tên:

HocSinh(int id, string name) {
mssv = id;
hoTen = name;
diemToan = 0.0;
diemVan = 0.0;
XuLy(); // tính diemTB
}

2.7.3. Phương thức thiết lập sao chép (copy constructor)

Trước khi đến với khái niệm phương thức thiết lập sao chép, chúng ta sẽ nhắc sơ lại về khái niệm tham chiếu (reference) trong C++:

  • Tham chiếu (hay tham biến) là một cái tên khác cho một đối tượng
  • Tham chiếu được khai báo bằng cách thêm kí tự ‘&’ vào trước tên biến, ví dụ:
HocSinh &r = hs;

Khi khai báo một tham chiếu r như trên, chương trình sẽ không sao chép giá trị của hs vào r mà chỉ xem r như là một cái tên khác của đối tượng hs.

  • Tham chiếu hằng: một tham chiếu mà không thể dùng để thay đổi giá trị của đối tượng nó được gắn vào, ví dụ:
const HocSinh &r = hs;
  • Một tham chiếu bình thường không thể được gắn với một biến hằng, một tham chiếu hằng có thể được gắn với một biến hằng lẫn biến bình thường.

Trở lại với vấn đề chính, phương thức thiết lập sao chép của một lớp đối tượng là phương thức thiết lập có 1 tham số đầu vào là tham chiếu tới một đối tượng của lớp đó. Mục đích của phương thức này là để sao chép dữ liệu của một đối tượng vào một đối tượng khác vừa được khai báo.

Vấn đề tại sao tham số đầu vào phải là tham chiếu sẽ được giải thích sau một lát nữa, trước hết chúng ta sẽ xem qua ví dụ về việc định nghĩa một phương thức thiết lập sao chép trong lớp HocSinh:

HocSinh(const HocSinh& temp) {
cout << "Copy constructor of HocSinh has been called"<<endl;
mssv = temp.mssv;
hoTen = temp.hoTen;
diemToan = temp.diemToan;
diemVan = temp.diemVan;
diemTB = temp.diemTB;
}

Trong chương trình, ta gọi thực hiện phương thức sao chép bằng cách khai báo:

HocSinh hs2(hs);

Hoặc

HocSinh hs2 = hs;

Lúc này chương trình sẽ tự động thực hiện dòng lệnh sau:

const HocSinh &temp = hs;

Chương trình tới đây sẽ không sao chép dữ liệu của hs vào temp mà chỉ xem temp như là một cái tên khác của hs, những thao tác lúc này được thực hiện bên trong thân phương thức ở trên chính là gán các giá trị của hs cho hs2.

Tới đây bạn đọc có thể nhận thấy rằng nếu temp không được khai báo là tham chiếu mà chỉ là một biến bình thường thì chương trình sẽ ngầm thực hiện dòng lệnh:

const HocSinh temp = hs;

Lúc này, trong quá trình thực hiện phương thức thiết lập sao chép để sao chép hs vào hs2, chương trình lại phải gọi thêm một phương thức thiết lập sao chép khác để sao chép hs vào temp, và cứ như vậy tạo thành một vòng lặp vô hạn.

Trong phương thức trên, ta khai báo tham chiếu temp là hằng để đảm bảo đối số truyền vào không thể bị sửa đổi một cách vô ý, cũng như đảm bảo rằng có thể truyền vào phương thức một đối số hằng.

*Một số trường hợp khác mà phương thức thiết lập sao chép được gọi thực hiện:

  • Khi truyền một đối số vào lời gọi hàm của một hàm có tham số tương ứng không phải là tham chiếu (như ví dụ ở trên).
  • Khi kết thúc lời gọi hàm, trả về một đối tượng mà kiểu dữ liệu trả về của hàm không phải tham chiếu.
  • Khi khởi tạo các phần tử của một mảng sử dụng dấu ngoặc nhọn, ví dụ:
HocSinh arr[2] = {hs};

Lúc này chương trình gọi phương thức thiết lập sao chép để sao chép hs vào phần tử đầu tiên của mảng, và gọi thực hiện phương thức thiết lập mặc định để khởi tạo giá trị cho phần tử thứ 2.

Khi chạy chương trình, sẽ thấy có dòng thông báo sau xuất hiện

Copy constructor of HocSinh has been called
Default constructor of HocSinh has been called

Một điều cần lưu ý ở đây là nếu chúng ta không tự tạo một phương thức thiết lập sao chép của riêng mình, chương trình sẽ cũng sẽ tự định nghĩa cho ta một phương thức thiết lập sao chép gần giống với ví dụ ở trên. Tuy nhiên, cần phải nhận thức được rằng phương thức thiết lập sao chép do chương trình tự định nghĩa không phải lúc nào cũng sẽ hoạt động như ý chúng ta muốn. Ta sẽ bắt gặp một vài ví dụ về vấn đề này ở các chương sau.

2.8. Phương thức phá hủy (Destructor)

a) Mục đích.

  • Thông thường, phương thức phá hủy có nhiệm vụ thu hồi lại tất cả các tài nguyên đã cấp phát cho đối tượng khi đối tượng hết phạm vi hoạt động (scope).

b) Đặc điểm.

  • Tên phương thức trùng với tên lớp nhưng có dấu ngã ở đằng trước.
  • Không có giá trị trả về.
  • Không có tham số đầu vào.
  • Được tự động gọi thực hiện trước khi đối tượng bị hủy.
  • Có và chỉ có duy nhất một phương thức phá huỷ trong 1 lớp.
  • Trong một quá trình sống của đối tượng có và chỉ có một lần phương thức phá hủy được gọi thực hiện mà thôi.

c) Ý nghĩa

  • Một cách dùng của phương thức phá hủy là để giải phóng bộ nhớ của các thuộc tính được cấp phát động trong một đối tượng. Nếu chúng ta không giải phóng các vùng nhớ này, nó sẽ bị tồn đọng lại và chiếm không gian không cần thiết.

Trước khi đến với các ví dụ về destructor, ta sẽ ôn lại một chút về cấp phát động:

Nhắc lại về cấp phát động:

  • Ghi nhớ: Miền giá trị của một biến con trỏ là địa chỉ ô nhớ
  • Trong câu lệnh HocSinh* arr; ta nói arr là một biến con trỏ kiểu HocSinh.
  • Về bản chất, tên của một mảng là một con trỏ giữ địa chỉ của phần tử đầu tiên trong mảng.
  • HocSinh* arr = new HocSinh[n]; có nghĩa là xin cấp phát một vùng nhớ có kích thước bằng kích thước của n kiểu HocSinh, nếu cấp phát thành công, giá trị của biến con trỏ arr sẽ là địa chỉ ô nhớ đầu tiên của vùng nhớ được cấp phát.
  • Lúc này arr được xem như là một mảng HocSinh có n phần tử.
  • Với cách khai báo bằng cấp phát động như vậy, các phần tử của mảng arr sẽ được lưu trong vùng nhớ heap.
  • Đối với các đối tượng được cấp phát động, sau khi sử dụng xong phải thu hồi bộ nhớ đã cấp phát bằng toán tử delete.

2.8.1. Phương thức phá hủy và cấp phát động

Ta sẽ cho ví dụ về một lớp đối tượng có tên là LopHoc để minh họa cho một trường hợp cần sử dụng phương thức phá hủy:

class LopHoc {
private:
HocSinh* arr;
int size;
public:
LopHoc(int size) {
this->size = size;
arr = new HocSinh[size];
}
};

Lớp đối tượng LopHoc có hai thành phần dữ liệu là con trỏ arr kiểu HocSinh tượng trưng cho mảng các HocSinh và biến số nguyên size tượng trưng cho sỉ số lớp. LopHoc có một phương thức thiết lập nhận 1 tham số đầu vào là sỉ số của lớp, ví dụ cho chương trình sau đây:

// ... khai báo thư viện, lớp, không gian tên 
int main() {
LopHoc lop(70);
return 0;
}

Thành phần arr của đối tượng lop lúc này được cấp phát cho một vùng nhớ có kích thước gấp 70 lần kích thước kiểu HocSinh. Ở đây, khi chương trình kết thúc, đối tượng lop sẽ bị phá hủy, kéo theo các dữ liệu thành phần là arrsize sẽ bị phá hủy theo, tuy nhiên vùng nhớ được cấp phát cho biến con trỏ arr vẫn còn đó mà chưa được thu hồi.

Để giải quyết vấn đề này, ta sẽ định nghĩa cho lớp LopHoc một phương thức phá hủy như sau:

~LopHoc() {
cout << "Destructor has been called" << endl;
delete[] arr;
}

Lúc này, trước khi đối tượng lop bị hủy, chương trình sẽ tự động gọi thực hiện phương thức phá hủy vừa được định nghĩa ở trên để thu hồi bộ nhớ được cấp phát cho thành phần dữ liệu arr, rồi sau đó mới thực hiện việc hủy đối tượng.

Nếu chúng ta không tự định nghĩa một phương thức phá hủy, chương trình cũng sẽ tự định cho ta một phiên bản như đây:

~LopHoc() { }

Phương thức phá hủy này không thực hiện bất cứ thao tác gì nên có thân hàm rỗng. Đối với những lớp đối tượng có thành phần được khai báo tĩnh thì có thể sử dụng phiên bản do chương trình tự định nghĩa mà không cần phải tự định nghĩa một phiên bản riêng. Một số trường hợp khác mà phương thức phá hủy được gọi:

  • Đối tượng bị hủy khi ra khỏi phạm vi hoạt động.
  • Đối tượng được cấp phát động bị hủy khi sử dụng toán tử delete lên biến con trỏ trỏ vào nó, ví dụ:
HocSinh* hsPtr = new HocSinh();
delete hsPtr; // Phương thức phá hủy của lớp HocSinh được gọi
  • Các dữ liệu thành viên bị hủy khi đối tượng chúng thuộc về bị hủy.

2.8.2. Phương thức phá hủy và phương thức thiết lập sao chép

Khi thiết kế các lớp đối tượng, có một quy luật là nếu một lớp cần phải tự định nghĩa phương thức phá hủy, thì lớp đó cũng cần tự định nghĩa một phương thức thiết lập sao chép của riêng mình.

Trong ví dụ về lớp LopHoc ở trên, nếu không làm gì thêm, chương trình sẽ tự định nghĩa cho ta một phương thức thiết lập sao chép như sau:

LopHoc(const LopHoc& temp) {
this->arr = temp.arr; //sau bước này this->arr và temp.arr cùng nắm giữ một vùng nhớ
this->size = temp.size;
}

Tại dòng số 2, địa chỉ mà biến-con-trỏ-arr-thuộc-đối-tượng-temp đang nắm giữ được sao chép vào biến con trỏ arr của đối tượng đang thực hiện lời gọi phương thức. Lúc này hai con trỏ có giá trị bằng nhau, tức là chúng đang cùng nắm giữ một vùng nhớ.

Khi thực hiện đoạn chương trình sau đây:

LopHoc item1(5);
{
LopHoc item2 = item1;
// Copy constructor do chương trình tự định nghĩa được gọi.
// Destructor được gọi trên item2.
}
item1.Xuat(); // lỗi! item1 bây giờ bị mất vùng nhớ.

Trong phạm vi từ dòng số 2 đến dòng số 5, item2 được khởi tạo bằng copy constructor do chương trình tự định nghĩa, sao chép dữ liệu từ item1. Khi item2 ra khỏi phạm vi, destructor của lớp LopHoc được gọi trên item2 thu hồi vùng nhớ được cấp phát cho biến này nhưng cũng “vô tình” thu hồi vùng nhớ được cấp phát cho item1 vì thành phần arr trong hai đối tượng này đang cùng sở hữu một vùng nhớ.

Để tránh lỗi trên, ta cần phải định nghĩa cho lớp LopHoc một phương thức thiết lập sao chép như dưới đây:

LopHoc(const LopHoc& temp) {
size = temp.size;
arr = new HocSinh[size];
for (int i = 0; i < size; ++i) {
arr[i] = temp.arr[i];
}
}

Lúc này, thành phần arr trong 2 biến item1item2 sẽ giữ địa chỉ của 2 vùng nhớ khác nhau, các thao tác trên đối tượng này sẽ không ảnh hưởng tới đối tượng kia.

Ví dụ trên cho thấy không phải lúc nào các phương thức do chương trình tự định nghĩa cũng hoạt động như ý chúng ta mong muốn, nên ta cần phải chú ý khi nào thì nên tự định nghĩa các phương thức thiết lập của riêng mình.

Trong thực tế, việc quản lí bộ nhớ cho các đối tượng được cấp phát động thường gây khó khăn và dễ phát sinh ra lỗi, vì thế ngôn ngữ C++ có hỗ trợ cho ta các thư viện và lớp đối tượng được cài đặt sẵn như vector (có công dụng như một mảng động), shared_ptr (con trỏ tự thu hồi bộ nhớ), … để việc lập trình trở nên dễ dàng hơn.

2.9. Thành phần tĩnh (Static member)

Các thành phần tĩnh là các thành phần thuộc về cả một lớp chứ không thuộc về một đối tượng cụ thể. Điều này có nghĩa là tất cả các đối tượng của một lớp đều chia sẻ chung một thành phần tĩnh, và nó có thể được truy cập mà không cần thông qua một đối tượng.

Có hai loại thành phần tĩnh:

  • Các thuộc tính (dữ liệu thành viên) tĩnh (Static data member)
  • Hàm thành viên (phương thức) tĩnh (Static member function)

Một ví dụ về trường hợp cần sử dụng thành phần tĩnh là khi ta cần biết có bao nhiêu đối tượng của một lớp đã được tạo, lúc này lớp đó cần có một thuộc tính tĩnh gọi là biến đếm. Để ý rằng biến đếm ở đây thuộc về cả lớp đối tượng đó chứ không phải là thuộc tính của một đối tượng cụ thể, vì nó đại diện cho số đối tượng đã được tạo.

2.9.1 Khởi tạo thành viên tĩnh

Muốn khởi tạo một thành viên tĩnh chúng ta sẽ thêm từ khóa static vào trước dòng khai báo của nó. Như các thành viên khác thì thành viên tĩnh cũng có thể được khai báo như là một thành phần private, public hay là protected.

Ví dụ , chúng ta sẽ thêm vào lớp HocSinh một thuộc tính tĩnh là demHS để đếm số lượng học sinh đã được tạo, và một hàm thành viên tĩnh tên là getDemHS để lấy giá trị của demHS:

class HocSinh {
private:
static int demHS; // Biến static
public:
static int getDemHS(); // Hàm static
// ... phần còn lại như các ví dụ trên
};

Thuộc tính tĩnh không phải là thuộc tính của một đối tượng nào cả, vì vậy mỗi đối tượng thuộc lớp HocSinh được tạo ra sẽ chỉ có 5 thuộc tính là: mssv,hoTen,diemToan, diemVan,diemTB mà không có thuộc tính demHS. Thêm vào đó, biến demHS được tạo ra ngay cả khi lớp chưa có một đối tượng nào, chỉ có một phiên bản của nó tồn tại từ đầu cho tới cuối chương trình và được các đối tượng của lớp HocSinh sử dụng.

Tương tự như vậy, một hàm thành viên tĩnh không gắn với bất kì đối tượng nào (không tồn tại con trỏ this bên trong một hàm thành viên tĩnh) nên ta không thể truy cập đến các thuộc tính non-static của lớp bên trong phương thức tĩnh.

2.9.2. Cách gọi các thành viên tĩnh

Chúng ta có thể truy cập trực tiếp một thành viên tĩnh thông qua toán tử phạm vi, ví dụ:

int dem = HocSinh::getDemHS();	
// gọi hàm thành viên tĩnh thông qua toán tử phạm vi

Mặc dù các thành viên tĩnh không phải là một phần của đối tượng, chúng ta vẫn có thể dùng các đối tượng của một lớp để truy cập đến các thành phần tĩnh của lớp đó:

HocSinh h1;
int dem = h1.getDemHS();

Các hàm thành viên có thể truy cập trực tiếp đến các thành phần tĩnh mà không cần toán tử phạm vi. Trong ví dụ về lớp HocSinh, khi một đối tượng HocSinh được tạo ra thì các phương thức thiết lập sẽ làm thêm một việc đó là tăng giá trị của demHS lên một:

// Phương thức thiết lập mặc định:
HocSinh() {
// ... khởi tạo các giá trị mặc định
demHS++;
// truy cập đến demHS mà không cần toán tử phạm vi
}

2.9.3. Định nghĩa thành viên tĩnh

Đối với các hàm thành viên tĩnh, chúng có thể được định nghĩa bên trong hoặc ngoài thân của lớp như các phương thức khác. Khi chúng ta định nghĩa ở bên ngoài thì không dùng từ khóa static (Lưu ý: Phương thức tĩnh chỉ có thể truy cập đến các thuộc tính tĩnh)

// định nghĩa hàm getdemHS	
int HocSinh::getDemHS() {
return demHS;
}

Bởi vì các thuộc tính tĩnh không phải là một phần của một đối tượng, chúng không được khởi tạo khi gọi phương thức thiết lập trên các đối tượng của lớp. Vì thế nên trong đa số các trường hợp, ta sẽ khởi tạo giá trị cho các thuộc tính tĩnh bên ngoài thân lớp. Mỗi dữ liệu thành viên tĩnh chỉ được định nghĩa một lần. Như biến toàn cục , dữ liệu thành viên tĩnh được định nghĩa bên ngoài tất cả các hàm, do đó một khi được định nghĩa nó sẽ tồn tại cho đến khi chương trình kết thúc:

class HocSinh {
private:
// khai báo thuộc tính tĩnh demHS
static int demHS;
// ... phần còn lại như các ví dụ trên
};
// định nghĩa và khởi tạo demHS
int HocSinh::demHS = 0;

2.10. Hàm bạn, lớp bạn (Friends)

2.10.1. Hàm bạn

Hàm bạn là hàm có thể truy cập thành phần private hoặc protected của lớp xem nó là bạn.

Giả sử mình có lớp MyClass1 và hàm myFunc như sau:

class MyClass1 {
private:
int tpRiengTu;
};
void myFunc(MyClass1 mClass) {
cout << mClass.tpRiengTu; // Không hợp lệ
}

Như bạn thấy trong đoạn code trên, dòng in ra giá trị thuộc tính tpRiengTu là không hợp lệ do nó là thuộc tính private và không được phép truy cập từ bên ngoài. Tuy nhiên mọi chuyện sẽ khác nếu hàm myFunc là hàm bạn của lớp MyClass1.

Cú pháp khai báo hàm bạn đơn giản là thêm dòng khai báo của hàm đó vào bên trong lớp, kèm theo từ khóa friend ở trước . Vậy thì đối với ví dụ trên ta sẽ thêm như sau:

class MyClass1 {
friend void myFunc(MyClass1 mClass);
// ...
};

Lúc này hàm myFunc có thể truy cập vào TpRiengTu của MyClass.

Lưu ý: nhiều bạn thường nhầm hàm myFunc là thành phần của MyClass1. Hàm myFunc không phải là thành phần của lớp MyClass1, nó chỉ là một hàm bình thường (không cần có toán tử phạm vi (::) trước tên hàm khi định nghĩa).

2.10.2. Lớp bạn

Tương tự, lớp bạn là lớp có thể truy cập thành phần private hoặc protected của lớp xem nó là bạn. Giả sử mình có lớp MyClass2 như sau:

class MyClass2 {
public:
void myMethod(MyClass1 mClass) {
cout << mClass.tpRiengTu; // Không hợp lệ
}
};

Trong đoạn code trên, dòng in giá trị thuộc tính tpRiengTu trong phương thức myMethod của MyClass2 không hợp lệ do ta không thể truy cập thành phần private của MyClass1 từ bên ngoài.

Để lớp MyClass2 có thể truy cập thành phần private, protected của lớp MyClass1, ta thêm dòng khai báo của MyClass2 bên trong MyClass1, theo sau bởi từ khóa friend:

class MyClass1 {
friend class MyClass2;
// ...
}

Như vậy, lớp MyClass2 đã là bạn của lớp MyClass1, các phương thức của lớp MyClass2 sẽ có quyền truy cập các thành phần private, protected của lớp MyClass1 một cách hợp lệ.

Lưu ý: lớp bạn là mối quan hệ một chiều, có nghĩa là lớp này có thể xem lớp kia là bạn, nhưng không có nghĩa lớp kia xem lớp này là bạn.