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

Lớp và đối tượng trong C++

Lớp (khai báo bằng từ khóa class) là khuôn mẫu mô tả thuộc tính (dữ liệu) và phương thức (hành vi). Đối tượng (object) là một thể hiện cụ thể của lớp, có trạng thái riêng.

Hãy hình dung một bài toán quen thuộc: quản lý sinh viên trong một lớp học. Nếu chỉ dùng biến rời rạc, ta phải giữ một mảng tên, một mảng điểm, một mảng mã số, rồi tự tay canh cho chúng luôn khớp nhau theo cùng chỉ số. Chỉ cần một chỗ xáo trộn là dữ liệu của một sinh viên bị lệch sang người khác. Lập trình hướng đối tượng đề xuất cách gói khác: gom tên, điểm, mã số của cùng một sinh viên vào một đơn vị duy nhất, kèm luôn các hành vi thao tác lên chúng. Đơn vị đó chính là lớp, còn mỗi sinh viên cụ thể là một đối tượng.

Một phép so sánh dễ nhớ: lớp giống bản vẽ kỹ thuật của một mẫu nhà, còn đối tượng là các căn nhà thật xây theo bản vẽ đó. Bản vẽ chỉ có một, nhưng dựng được nhiều căn, mỗi căn chiếm một mảnh đất riêng và có thể sơn màu khác nhau. Vì thế ta khai báo lớp đúng một lần rồi sinh ra bao nhiêu đối tượng tùy nhu cầu, mỗi đối tượng giữ trạng thái độc lập.

Phép so sánh này chứa đúng cái ranh giới mà người mới hay vấp. Bản vẽ không có mái ngói thật, không có tường thật, và bạn không thể ở trong một bản vẽ. Tương tự, một lớp không giữ giá trị nào cả: viết class Point { int x, y; }; không tạo ra điểm nào trên bộ nhớ, nó chỉ dạy trình biên dịch rằng "khi có ai yêu cầu một Point, hãy cấp cho họ hai ô số nguyên tên xy". Chỉ dòng Point p; mới thật sự sinh ra một đối tượng có ô nhớ riêng. Vì thế câu hỏi "giá trị của x trong lớp Point là bao nhiêu" là vô nghĩa, phải hỏi "giá trị của x trong đối tượng p là bao nhiêu".

Từ ranh giới đó suy ra một hệ quả thực dụng. Mọi thứ chung cho mọi thể hiện, chẳng hạn công thức tính tổng hai tọa độ, thuộc về lớp và chỉ viết một lần. Mọi thứ riêng của từng thể hiện, chẳng hạn giá trị cụ thể của xy, thuộc về đối tượng và được nhân bản theo số đối tượng. Khi thiết kế một lớp, câu hỏi đầu tiên nên tự đặt là: cái này chung hay riêng?

class hay struct?

Trong C++, classstruct gần như giống hệt nhau, chỉ khác mặc định quyền truy cập (class mặc định private, struct mặc định public). Khi học lập trình hướng đối tượng, ta dùng class làm từ khóa chính và thêm public: để mở quyền truy cập cho các thành viên.

Ý tưởng cốt lõi

  • Lớp gói dữ liệu + hàm xử lý dữ liệu đó vào một chỗ.
  • Mỗi đối tượng có bản sao thuộc tính riêng (p.x của đối tượng này khác đối tượng kia).
  • Phương thức thao tác trên chính đối tượng gọi nó (qua con trỏ ẩn this).

Mã nguồn

#include <iostream>
using namespace std;

class Point {
public:
int x, y; // thuộc tính
int tong() { return x + y; } // phương thức
};

int main() {
Point p; // tạo đối tượng trên ngăn xếp
p.x = 3;
p.y = 4;
int s = p.tong(); // gọi phương thức
cout << s; // 7
}

Thử ngay: xem lớp và đối tượng chạy từng bước

Tạo và dùng một đối tượng C++
1class Point {
2public:
3 int x, y;
4 int tong() { return x + y; }
5};
6
7int main() {
8 Point p;
9 p.x = 3;
10 p.y = 4;
11 int s = p.tong();
12 cout << s;
13}
Ngăn xếp stack
main()
#1Point
x?
y?
Bộ nhớ động heap
(trống)
Khai báo Point p, một đối tượng nằm trên NGĂN XẾP. Hai thuộc tính x, y chưa gán nên là giá trị rác.
1/5

Thuộc tính và phương thức, hai nửa của một lớp

Một lớp gồm hai loại thành viên. Thuộc tính (dữ liệu thành viên) mô tả một đối tượng đang giữ trạng thái gì, ví dụ xy của một điểm, hay tendiem của một sinh viên. Phương thức (hàm thành viên) mô tả đối tượng làm được gì với dữ liệu của chính nó, ví dụ tính tổng hai tọa độ hay in thông tin sinh viên. Cái hay của việc gói chung là mọi hành vi đều nằm ngay cạnh dữ liệu mà nó thao tác, nên khi cần sửa cách tính, ta biết chính xác phải sửa ở đâu.

Điểm cần khắc sâu: thuộc tính thuộc về từng đối tượng, mỗi đối tượng có bản sao riêng; còn phương thức được viết một lần cho cả lớp và dùng chung. Khi ta gọi p.tong(), phương thức đọc x, y của riêng p; khi ta gọi q.tong(), cùng đoạn mã đó lại đọc x, y của riêng q. Cơ chế giúp phương thức biết mình đang làm việc trên đối tượng nào là con trỏ ẩn this, ta sẽ bàn ở mục riêng phía sau.

Xem chạy từng bước: hai đối tượng độc lập

Bản trace đầu theo dõi vòng đời của một đối tượng duy nhất. Ở đây ta đặt cạnh nhau hai đối tượng ab cùng lớp, khởi tạo giống hệt nhau, rồi chỉ sửa a. Hãy quan sát kỹ: mỗi đối tượng có một ô nhớ trường riêng trên ngăn xếp, nên khi a.x đổi thành 99 thì b.x vẫn nguyên 1. Đây là điểm tương phản với bí danh (hai tên trỏ chung một ô nhớ): ở đây ab là hai vùng nhớ tách biệt.

Hai đối tượng cùng lớp, trạng thái tách biệt C++
1class Diem {
2public:
3 int x, y;
4};
5
6int main() {
7 Diem a;
8 a.x = 1;
9 a.y = 2;
10 Diem b;
11 b.x = 1;
12 b.y = 2;
13 a.x = 99;
14 cout << a.x << "," << b.x;
15}
Ngăn xếp stack
main()
#1Diem
x?
y?
Bộ nhớ động heap
(trống)
Khai báo đối tượng a trên ngăn xếp. Hai trường x, y chưa gán nên mang giá trị rác.
1/8

Khai báo lớp, từng phần một

Cú pháp khai báo bắt đầu bằng từ khóa class, theo sau là tên lớp và một thân lớp trong cặp ngoặc nhọn. Sau ngoặc nhọn đóng phải có dấu chấm phẩy, đây là lỗi cú pháp rất hay gặp với người mới. Bên trong thân lớp, ta dùng nhãn public: để mở quyền truy cập cho các thành viên đứng sau nó, và private: để che chúng lại.

#include <iostream>
#include <string>
using namespace std;

class SinhVien {
public:
string ten; // thuộc tính: họ tên
double diem; // thuộc tính: điểm trung bình

void inThongTin() { // phương thức: xuất thông tin
cout << ten << " - " << diem << "\n";
}
bool dau() { // phương thức: kiểm tra đạt
return diem >= 5.0;
}
};

int main() {
SinhVien sv;
sv.ten = "An";
sv.diem = 7.5;
sv.inThongTin(); // An - 7.5
cout << (sv.dau() ? "Dat" : "Chua dat"); // Dat
}

Ở ví dụ trên, SinhVien là lớp còn sv là một đối tượng. Đối tượng sv có vùng nhớ riêng cho tendiem. Phương thức inThongTindau thuộc về lớp, nhưng khi gọi qua sv thì chúng thao tác trên đúng dữ liệu của sv. Nếu tạo thêm đối tượng khác, đối tượng đó lại có bộ ten, diem hoàn toàn tách biệt.

Tạo đối tượng: trên ngăn xếp hay qua con trỏ

Có hai cách tạo đối tượng. Cách thứ nhất là khai báo một biến thường, đối tượng nằm trên ngăn xếp và bị hủy tự động khi ra khỏi phạm vi. Cách thứ hai là cấp phát động bằng new, đối tượng nằm trên vùng nhớ heap và phải tự giải phóng bằng delete. Khi cầm trực tiếp đối tượng, ta truy cập thành viên bằng toán tử chấm .; khi cầm một con trỏ trỏ tới đối tượng, ta dùng toán tử mũi tên ->.

Point p; // đối tượng trên ngăn xếp
p.x = 1; // chấm: cầm trực tiếp đối tượng

Point* ptr = new Point; // đối tượng trên heap, ptr giữ địa chỉ
ptr->x = 2; // mũi tên: cầm địa chỉ
(*ptr).x = 2; // cách viết tương đương nhưng dài dòng
delete ptr; // trả lại vùng nhớ đã cấp

Biểu thức ptr->x chỉ là cách viết gọn của (*ptr).x: trước hết lấy nội dung con trỏ trỏ tới, rồi mới lấy thành viên x. Vì gọn và dễ đọc hơn nhiều nên trong thực tế gần như luôn dùng -> khi làm việc qua con trỏ.

Hai cách tạo đối tượng khác nhau ở chỗ nào ngoài cú pháp? Câu trả lời nằm ở ai chịu trách nhiệm hủy. Với Point p;, đối tượng nằm trên ngăn xếp, và ngăn xếp có kỷ luật rất chặt: khi luồng thực thi ra khỏi khối lệnh chứa khai báo, mọi thứ khai báo trong khối đó bị hủy tự động. Bạn không phải làm gì, và cũng không thể quên. Đổi lại, tuổi thọ của đối tượng bị buộc chặt vào phạm vi: nó không sống lâu hơn hàm đã tạo ra nó.

Với new Point, đối tượng nằm trên heap, một vùng nhớ không bị ràng buộc bởi phạm vi. Nó sống cho tới khi có ai đó gọi delete, dù chuyện đó xảy ra ở hàm nào, sau bao lâu. Đây chính là điều ta cần khi số lượng đối tượng chỉ biết được lúc chạy, hoặc khi đối tượng phải sống lâu hơn hàm sinh ra nó. Nhưng tự do đi kèm trách nhiệm, vì heap không tự dọn. Quên delete thì vùng nhớ bị chiếm mãi tới khi chương trình thoát, gọi là rò rỉ bộ nhớ. Gọi delete hai lần trên cùng một địa chỉ, hoặc tiếp tục dùng con trỏ sau khi đã delete, còn tệ hơn: chương trình có thể chạy sai khó hiểu hoặc sập ở một chỗ chẳng liên quan gì tới chỗ gây lỗi.

Quy tắc cho người mới rất gọn: mặc định tạo đối tượng trên ngăn xếp vì nó an toàn và không tốn công quản lý; chỉ dùng new khi thật sự có lý do, và khi đã dùng thì mỗi new phải có đúng một delete tương ứng.

Mỗi new phải có đúng một delete

Quên delete gây rò rỉ bộ nhớ. Gọi delete hai lần hoặc dùng con trỏ sau khi delete gây hành vi không xác định, và triệu chứng thường xuất hiện ở nơi khác hẳn nguyên nhân nên rất khó lần ra. Nguyên nhân gốc của cả hai lỗi là như nhau: heap giao quyền quyết định vòng đời cho lập trình viên, nên vòng đời đó phải được nghĩ kỹ chứ không được để tùy hứng.

Con trỏ this sơ lược

Mỗi khi một phương thức được gọi qua một đối tượng, bên trong nó có sẵn một con trỏ ẩn tên this luôn trỏ tới chính đối tượng đang gọi. Nhờ this, phương thức biết mình thao tác trên đối tượng nào. Thông thường ta không cần viết this ra vì trình biên dịch tự hiểu, nhưng nó hữu ích rõ rệt khi tên tham số trùng tên thuộc tính: khi đó this->x chỉ rõ ta muốn nói tới thuộc tính chứ không phải tham số.

class Point {
public:
int x, y;
void datX(int x) { // tham số x che khuất thuộc tính x
this->x = x; // this->x là thuộc tính, x là tham số
}
};

Vì sao lại cần một con trỏ ẩn như vậy? Phương thức được viết một lần cho cả lớp, nhưng phải chạy được trên vô số đối tượng khác nhau. Đoạn mã return x + y; bên trong tong() không thể tự biết x nào, y nào, vì mỗi đối tượng có một cặp x, y riêng. Cách trình biên dịch giải quyết là âm thầm truyền địa chỉ của đối tượng gọi vào phương thức như một tham số vô hình tên this. Câu lệnh bạn viết là return x + y; nhưng cái thật sự chạy tương đương return this->x + this->y;. Nói cách khác, p.tong() gần như là Point::tong(&p) viết bằng cú pháp đẹp hơn.

Hiểu điều này thì nhiều thứ trở nên hiển nhiên. Phương thức đọc được thuộc tính mà không cần truyền tham số, vì this đã âm thầm cung cấp đối tượng. Hai đối tượng gọi cùng một phương thức cho hai kết quả khác nhau, vì this trỏ tới hai chỗ khác nhau. Và khi tham số trùng tên thuộc tính thì tham số thắng, vì tên trần x được tìm trong phạm vi gần nhất; muốn chỉ đích danh thuộc tính thì phải đi qua this->x.

class hay struct, khác nhau ở đâu

Trong C++, classstruct gần như giống hệt nhau: cả hai đều khai báo được thuộc tính lẫn phương thức. Khác biệt ngôn ngữ duy nhất là mức truy cập mặc định. Trong class, nếu không ghi gì thì mọi thành viên mặc định là private, tức bên ngoài không chạm tới được. Trong struct, mặc định là public. Quy ước thực hành thường gặp: dùng struct cho những gói dữ liệu thuần, đơn giản, không cần che; dùng class khi có đóng gói, có phương thức canh giữ tính hợp lệ của dữ liệu. Khi học lập trình hướng đối tượng, ta lấy class làm từ khóa chính và thêm public: để mở quyền truy cập khi cần.

Đóng gói sơ lược

Cho tới giờ ta để mọi thuộc tính ở public cho dễ học. Nhưng hãy hình dung một lớp TaiKhoan có thuộc tính soDu để ở public. Bất kỳ đoạn mã nào ở bất kỳ đâu trong chương trình cũng có thể viết tk.soDu = -999;, và lớp không có cách nào ngăn cản. Một tài khoản có số dư âm bậy bạ là trạng thái vô nghĩa, nhưng lớp đã tự tay cho phép nó xảy ra.

Đóng gói (encapsulation) là cách trả lời vấn đề đó: giấu dữ liệu đằng sau private, và chỉ cho bên ngoài tác động qua những phương thức public do chính lớp kiểm soát. Khi ấy mọi con đường dẫn tới việc thay đổi soDu đều đi qua một cửa duy nhất, và ta có thể đặt kiểm tra ngay tại cửa đó.

#include <iostream>
using namespace std;

class TaiKhoan {
private:
double soDu = 0.0; // hidden: nobody can touch it directly

public:
bool napTien(double tien) { // the only door in
if (tien <= 0) {
return false; // reject an invalid amount
}
soDu += tien;
return true;
}
double laySoDu() const { // read-only access
return soDu;
}
};

int main() {
TaiKhoan tk;
tk.napTien(100.0);
tk.napTien(-50.0); // rejected, balance unchanged
// tk.soDu = -999; // compile error: soDu is private
cout << tk.laySoDu(); // 100
}

Cái lợi ở đây không phải là "giấu cho bí mật" mà là thu hẹp số nơi có thể làm hỏng dữ liệu. Với thuộc tính public, bất biến "số dư không âm" phải được mọi đoạn mã trong chương trình tôn trọng, và bạn không thể kiểm tra hết. Với thuộc tính private, bất biến ấy chỉ cần vài phương thức của chính lớp tôn trọng, và bạn đọc hết chúng trong vài chục dòng. Khi có lỗi số dư sai, bạn biết chính xác phải soi ở đâu.

Đóng gói cũng có cái giá: code dài hơn, phải viết thêm phương thức đọc và ghi, và với gói dữ liệu đơn thuần thì bọc kín mọi thứ chỉ làm rườm rà. Quy ước thực dụng: kiểu chỉ để chở dữ liệu, không có bất biến nào cần canh giữ, thì dùng struct với thành viên public; kiểu có bất biến phải giữ thì dùng class với thành viên private và phương thức public canh cửa. Cách viết những phương thức canh cửa sẽ được bàn kỹ ở bài về hàm khởi tạo và getter/setter.

Vòng đời của một đối tượng

Một đối tượng không xuất hiện rồi đứng yên; nó đi qua ba giai đoạn rõ rệt, và biết rõ ba giai đoạn ấy giúp bạn tránh phần lớn lỗi liên quan tới bộ nhớ trong C++.

Giai đoạn một là tạo lập. Bộ nhớ cho các thuộc tính được cấp phát, trên ngăn xếp nếu khai báo biến thường, trên heap nếu dùng new. Ngay sau đó, C++ gọi một phương thức đặc biệt gọi là hàm khởi tạo để đưa đối tượng về trạng thái hợp lệ. Nếu ta không viết hàm khởi tạo và cũng không cho giá trị ban đầu, các thuộc tính kiểu cơ bản như int sẽ mang giá trị rác, tức bất kỳ thứ gì còn sót lại trong ô nhớ đó. Đây chính là lý do Point p; cout << p.tong(); cho ra một con số vô nghĩa: đối tượng đã tồn tại nhưng chưa được đưa về trạng thái có ý nghĩa.

Giai đoạn hai là sử dụng: đối tượng giữ trạng thái, các phương thức được gọi và có thể làm trạng thái ấy đổi. Giai đoạn ba là hủy: đối tượng trên ngăn xếp bị hủy tự động khi ra khỏi phạm vi, còn đối tượng trên heap chỉ bị hủy khi có delete. Trước khi vùng nhớ được thu hồi, C++ gọi một phương thức đặc biệt khác gọi là hàm hủy, nơi lớp có cơ hội trả lại những tài nguyên nó đã mượn.

Hai bài học rút ra ngay từ bây giờ. Thứ nhất, đừng dùng một đối tượng khi nó chưa được khởi tạo tử tế: hãy cho thuộc tính giá trị ban đầu ngay tại chỗ khai báo trong lớp, hoặc gán đủ giá trị ngay sau khi tạo. Thứ hai, hãy nghĩ về vòng đời trước khi chọn nơi cấp phát: đối tượng chỉ sống trong một hàm thì đặt trên ngăn xếp, còn phải sống lâu hơn hàm sinh ra nó thì mới cần heap, kèm theo trách nhiệm delete.

Lỗi thường gặp với lớp và đối tượng
  • Quên dấu chấm phẩy sau ngoặc nhọn đóng của lớp: }; bắt buộc phải có.
  • Dùng . với một con trỏ (ptr.x) hoặc dùng -> với một đối tượng thường (p->x): trình biên dịch sẽ báo lỗi. Quy tắc dễ nhớ, cầm đồ vật thì dùng chấm, cầm địa chỉ thì dùng mũi tên.
  • Gọi phương thức khi thuộc tính chưa được khởi tạo: kết quả là giá trị rác, không xác định. Nên gán giá trị ban đầu ngay khi tạo đối tượng.
  • Tưởng nhầm các đối tượng cùng lớp dùng chung thuộc tính: mỗi đối tượng giữ bản sao riêng, sửa cái này không đụng cái kia.

Bài tập thực hành

Bài tập 1: thêm phương thức tính hiệu

Mở rộng lớp Point ở trên: thêm một phương thức hieu() trả về x - y. Trong main, tạo một đối tượng với x = 10, y = 3, rồi in ra cả tổng và hiệu.

Lời giải chi tiết

Phương thức mới nằm bên trong lớp nên nó truy cập thẳng vào xy của chính đối tượng gọi nó (qua con trỏ ẩn this), giống hệt tong(). Ta chỉ cần đổi phép cộng thành phép trừ. Trong main, sau khi gán giá trị cho hai thuộc tính, ta gọi lần lượt p.tong()p.hieu().

#include <iostream>
using namespace std;

class Point {
public:
int x, y;
int tong() { return x + y; } // sum of the two fields
int hieu() { return x - y; } // difference of the two fields
};

int main() {
Point p;
p.x = 10;
p.y = 3;
cout << p.tong() << " " << p.hieu(); // 13 7
}

Ý chính: mọi phương thức trong lớp đều thao tác trên thuộc tính của chính đối tượng gọi nó, nên thêm hành vi mới chỉ là viết thêm một hàm bên trong lớp.

Bài tập 2: lớp nhiều thuộc tính

Viết lớp HinhChuNhat có hai thuộc tính rongcao (số nguyên). Thêm hai phương thức: dienTich() trả về diện tích và chuVi() trả về chu vi. Trong main, tạo một hình chữ nhật rong = 5, cao = 3 rồi in ra diện tích và chu vi.

Lời giải chi tiết

Lớp lần này gói hai thuộc tính cùng hai phương thức xử lý chúng. Diện tích là rong * cao, chu vi là 2 * (rong + cao). Vì cả hai phương thức đều đọc thuộc tính của chính đối tượng, ta không cần truyền tham số nào.

#include <iostream>
using namespace std;

class HinhChuNhat {
public:
int rong, cao;
int dienTich() { return rong * cao; } // area
int chuVi() { return 2 * (rong + cao); } // perimeter
};

int main() {
HinhChuNhat hcn;
hcn.rong = 5;
hcn.cao = 3;
cout << hcn.dienTich() << " " << hcn.chuVi(); // 15 16
}

Ý chính: một lớp gom nhiều thuộc tính liên quan và các phương thức tính toán dựa trên chúng vào cùng một khối nhất quán.

Bài tập 3: hai đối tượng có trạng thái riêng

Dùng lại lớp HinhChuNhat ở bài tập 2. Tạo hai đối tượng ab với kích thước khác nhau (a là 5x3, b là 4x4), in ra diện tích của từng cái để thấy mỗi đối tượng giữ trạng thái độc lập.

Lời giải chi tiết

Mỗi đối tượng có bản sao thuộc tính riêng, nên gán giá trị cho a không ảnh hưởng tới b. Khi gọi a.dienTich(), phương thức đọc rong, cao của riêng a; khi gọi b.dienTich(), nó đọc của riêng b. Đây chính là điểm cốt lõi: lớp là khuôn mẫu, còn trạng thái thuộc về từng đối tượng cụ thể.

#include <iostream>
using namespace std;

class HinhChuNhat {
public:
int rong, cao;
int dienTich() { return rong * cao; } // area
};

int main() {
HinhChuNhat a;
a.rong = 5;
a.cao = 3;

HinhChuNhat b;
b.rong = 4;
b.cao = 4;

cout << a.dienTich() << " " << b.dienTich(); // 15 16
}

Ý chính: hai đối tượng cùng lớp vẫn có vùng nhớ thuộc tính tách biệt, nên trạng thái của đối tượng này hoàn toàn độc lập với đối tượng kia.

Bài tập 4: đối tượng trên heap và toán tử mũi tên

Dùng lại lớp HinhChuNhat. Tạo một đối tượng trên heap bằng new, gán rong = 6, cao = 2 qua con trỏ, in diện tích, rồi giải phóng đúng cách. Bảo đảm mỗi new có đúng một delete.

Lời giải chi tiết

Bước 1: new HinhChuNhat cấp phát đối tượng trên heap và trả về địa chỉ của nó, nên biến nhận phải là con trỏ HinhChuNhat*. Bước 2: vì đang cầm địa chỉ chứ không cầm đối tượng, mọi truy cập thành viên phải dùng ->, không dùng dấu chấm. Bước 3: khởi tạo đủ cả hai thuộc tính trước khi gọi dienTich(), nếu không ta đọc phải giá trị rác. Bước 4: gọi delete đúng một lần khi không cần đối tượng nữa; gán nullptr sau đó là một thói quen tốt để tránh lỡ tay dùng lại con trỏ đã chết.

#include <iostream>
using namespace std;

class HinhChuNhat {
public:
int rong = 0; // safe default: no garbage
int cao = 0;
int dienTich() { return rong * cao; } // area
};

int main() {
HinhChuNhat* p = new HinhChuNhat; // object lives on the heap
p->rong = 6; // arrow: we hold an address
p->cao = 2;
cout << p->dienTich() << "\n"; // 12

delete p; // exactly one delete for one new
p = nullptr; // avoid using a dangling pointer
}

Ý chính: trên heap, vòng đời của đối tượng do bạn quyết định chứ không do phạm vi quyết định, nên bạn phải tự đóng vòng đời ấy bằng delete. Cho thuộc tính giá trị mặc định ngay trong lớp giúp đối tượng không bao giờ rơi vào trạng thái rác, kể cả khi ai đó quên gán.

Tự kiểm tra

Nếu gọi p.tong() ngay khi chưa gán x, y thì sao?

xy chứa giá trị rác (chưa khởi tạo) nên kết quả không xác định. Bài học: nên khởi tạo thuộc tính ngay khi tạo đối tượng (xem bài Khởi tạo và hủy).

Câu hỏi tự kiểm

Kiểm tra nhanh: lớp và đối tượng0/4 đúngchưa trả lời
  1. 1Dòng khai báo class Point { int x, y; }; tạo ra thứ gì?
  2. 2Có con trỏ Point* ptr trỏ tới một đối tượng, cách truy cập thành viên x đúng là gì?
  3. 3Vì sao hai đối tượng cùng lớp gọi cùng một phương thức lại cho kết quả khác nhau?
  4. 4Khác biệt ngôn ngữ duy nhất giữa class và struct trong C++ là gì?

Tóm tắt

Bài này dựng nền cho toàn bộ phần hướng đối tượng. Ý niệm gốc chỉ có một: tách bạch giữa khuôn mẫuthể hiện. Lớp là khuôn mẫu, nó mô tả một loại thực thể gồm những dữ liệu gì và làm được những gì, nhưng bản thân nó không giữ giá trị nào. Đối tượng là thể hiện, có vùng nhớ riêng và trạng thái riêng. Từ một lớp sinh ra bao nhiêu đối tượng cũng được, và trạng thái của chúng hoàn toàn độc lập với nhau.

Cầu nối giữa hai thứ đó là con trỏ ẩn this. Phương thức được viết một lần cho cả lớp, nhưng mỗi lần gọi, this cho nó biết đang làm việc trên đối tượng nào. Về nơi cất đối tượng, ngăn xếp cho ta sự an toàn và tự động, đổi lấy việc tuổi thọ bị buộc vào phạm vi; heap cho ta tự do về vòng đời, đổi lấy trách nhiệm delete. Mặc định nên chọn ngăn xếp.

Về cách truy cập, chỉ có một quy tắc: cầm đối tượng thì dùng dấu chấm, cầm địa chỉ thì dùng mũi tên. Về classstruct, khác biệt ngôn ngữ duy nhất là mức truy cập mặc định, còn khác biệt thực hành là ý định. Và đóng gói không nhằm giữ bí mật mà nhằm thu hẹp số nơi có thể làm hỏng dữ liệu, để khi có lỗi thì bạn biết chính xác phải soi ở đâu.

Tóm tắt nhanh

  • Lớp gói thuộc tính (dữ liệu) và phương thức (hành vi) vào một đơn vị, khai báo bằng class và kết thúc bằng dấu chấm phẩy.
  • Đối tượng là một thể hiện cụ thể của lớp, mỗi đối tượng có vùng nhớ thuộc tính riêng.
  • Truy cập thành viên bằng . khi cầm đối tượng, bằng -> khi cầm con trỏ.
  • Phương thức luôn làm việc trên đối tượng gọi nó, nhờ con trỏ ẩn this.
  • class mặc định private, struct mặc định public, còn lại giống nhau.
  • Đối tượng trên ngăn xếp hủy tự động theo phạm vi; đối tượng trên heap sống tới khi có delete, mỗi new đúng một delete.
  • Vòng đời gồm tạo lập, sử dụng, hủy; thuộc tính chưa khởi tạo mang giá trị rác nên hãy cho giá trị mặc định ngay trong lớp.
  • Đóng gói giấu dữ liệu sau private để thu hẹp số nơi có thể làm hỏng bất biến của đối tượng.
Ghi nhớ

Lớp = khuôn mẫu (dữ liệu + hành vi). Đối tượng = một thể hiện có trạng thái riêng. Phương thức luôn thao tác trên đối tượng đã gọi nó.