Đóng gói trong C++
Đóng gói (encapsulation) = ẩn dữ liệu bên trong (private) và chỉ cho phép
thao tác qua giao diện công khai (public) có kiểm soát. Nhờ đó dữ liệu luôn ở
trạng thái hợp lệ (giữ bất biến, invariant).
Đặt vấn đề
Hình dung bạn viết phần mềm cho ngân hàng. Số dư tài khoản không bao giờ được âm, không
được tăng từ hư không. Nếu thuộc tính soDu để public, bất kỳ dòng nào trong dự án
cũng có quyền viết tk.soDu = -999;. Chương trình vẫn biên dịch, vẫn chạy, và bạn chỉ
phát hiện sai sót khi khách hàng gọi điện phàn nàn.
Vấn đề không phải lập trình viên cố tình phá hoại, mà là dự án thật có hàng chục nghìn
dòng và nhiều người cùng sửa. Khi mọi nơi đều ghi được vào soDu, muốn tìm chỗ làm hỏng
dữ liệu bạn phải đọc cả dự án. Nếu soDu là private và chỉ napTien cùng rutTien
chạm được vào nó, lỗi chỉ có thể nằm trong hai phương thức đó. Đóng gói chính là công cụ
thu hẹp bề mặt sai sót như vậy.
Đóng gói không phải là giấu bí mật
Đây là chỗ hầu hết người mới học hiểu lệch, và hiểu lệch ngay từ cái tên. Chữ "ẩn dữ liệu" gợi cảm giác ta đang cất giấu điều gì đó khỏi những con mắt tò mò, như thể private là một cơ chế bảo mật. Nó không phải: bất kỳ ai cầm mã nguồn cũng đọc được thuộc tính private, ai chịu khó ép kiểu con trỏ thì thậm chí còn ghi được vào nó. Trình biên dịch không dựng bức tường thép, nó chỉ từ chối biên dịch một dòng lệnh.
Vậy private để làm gì? Để thu hẹp vùng ảnh hưởng khi có sai sót. Mọi phần mềm đủ lớn đều có lỗi, đó là điều chắc chắn chứ không phải điều tránh được, nên câu hỏi thiết kế đúng đắn không phải là làm sao để không bao giờ sai, mà là khi sai thì phải đọc bao nhiêu dòng mã để tìm ra. Nếu soDu là public trong một dự án ba mươi nghìn dòng, tập nghi phạm khi số dư bị âm chính là ba mươi nghìn dòng đó; nếu nó là private và lớp TaiKhoan dài tám mươi dòng, tập nghi phạm là tám mươi dòng. Đóng gói vì thế là một dạng lập luận cục bộ: nó cho phép bạn khẳng định một điều mạnh mà chỉ cần đọc một lớp. Nói cách khác, private chống nhầm lẫn chứ không chống phá hoại, và nó bảo vệ bạn khỏi chính bạn của sáu tháng sau, người đã quên mất rằng số dư không được âm.
Ý tưởng cốt lõi
private: chỉ các phương thức của lớp truy cập được; bên ngoài thì không.public: giao diện cho thế giới bên ngoài dùng.- Mọi thay đổi dữ liệu đi qua phương thức → có thể kiểm tra điều kiện.
Mã nguồn
#include <iostream>
using namespace std;
class TaiKhoan {
private:
long soDu = 0; // ẩn
public:
void napTien(long t) { if (t > 0) soDu += t; } // kiểm soát
long xem() { return soDu; }
};
int main() {
TaiKhoan tk;
tk.napTien(500);
tk.napTien(-100); // bị chặn
cout << tk.xem(); // 500
}
Thử ngay: xem đóng gói chạy từng bước
1class TaiKhoan {2private:3 long soDu = 0;4public:5 void napTien(long t) { if (t > 0) soDu += t; }6 long xem() { return soDu; }7};89int main() {10 TaiKhoan tk;11 tk.napTien(500);12 tk.napTien(-100);13 cout << tk.xem();14}
Vì sao phải giấu dữ liệu
Sinh viên mới học thường thấy private là phiền phức: gán thẳng tk.soDu = 500 rõ ràng
ngắn hơn gọi tk.napTien(500). Ba lợi ích sau giải thích vì sao ta chấp nhận cái phiền
phức đó.
Thứ nhất, dữ liệu luôn hợp lệ. Mọi con đường vào dữ liệu đều đi qua một cái cổng do bạn dựng, và ở cổng đó bạn đặt lính gác. Không có cửa sau.
Thứ hai, bạn được tự do đổi cách lưu trữ bên trong. Đây là lợi ích bị đánh giá thấp
nhất. Giả sử hôm nay TaiKhoan lưu một số nguyên soDu, sáu tháng sau nghiệp vụ đòi xem
lịch sử giao dịch, nên bạn muốn lưu danh sách các lần nạp rút rồi tính số dư bằng cách
cộng dồn. Nếu soDu từng là public, hàng trăm chỗ đang đọc trực tiếp tk.soDu sẽ hỏng
cùng lúc. Nếu nó là private và bên ngoài chỉ biết đến xem(), bạn thay ruột thoải mái:
số dư chuyển từ "được lưu" sang "được tính" mà không ai bên ngoài phải sửa một dòng nào.
Ranh giới public là hợp đồng, phần private là chuyện riêng của lớp.
Thứ ba, thu hẹp vùng phải đọc khi tìm lỗi. Số dư sai thì chỉ cần đọc các phương thức
của lớp TaiKhoan, không cần lục cả dự án.
Ba mức truy cập, và struct so với class
private là mức chặt nhất: chỉ phương thức của chính lớp đó, cùng các hàm và lớp được khai friend, mới chạm được, còn lớp con thì không. Đây phải là mặc định của bạn, vì nới lỏng về sau là chuyện dễ, còn siết lại một thứ mà cả dự án đã trót dùng là chuyện đau đớn. public là giao diện, là lời hứa của lớp với thế giới: mỗi thứ bạn đặt vào public là một thứ bạn cam kết duy trì, nên hợp đồng càng dài thì càng khó thoát.
protected là mức trung gian, lớp con truy cập được còn bên ngoài thì không. Nghe như một thoả hiệp đẹp, nhưng nó nguy hiểm hơn người ta tưởng: một thuộc tính protected thực chất là public đối với mọi lớp con, hiện tại và tương lai, mà bạn thì không kiểm soát được ai sẽ kế thừa lớp của mình, nên bạn vừa mất khả năng đổi cách lưu trữ bên trong. Lời khuyên thực hành: dữ liệu nên là private, và nếu lớp con cần đụng tới thì mở cho chúng một phương thức protected có kiểm soát thay vì phơi thẳng trường dữ liệu.
Còn struct khác class ở chỗ nào? Về mặt ngôn ngữ, khác biệt duy nhất là mức truy cập mặc định: thành viên của struct mặc định public, của class mặc định private; ngoài ra chúng giống hệt nhau, vì struct cũng có hàm khởi tạo, có phương thức, kế thừa được. Nhưng khác biệt về quy ước thì lớn hơn khác biệt về cú pháp. Quy tắc quyết định gọn: nếu bạn trả lời được câu hỏi "mệnh đề nào phải luôn đúng cho đối tượng này" bằng một câu có nội dung, hãy dùng class và giấu dữ liệu; nếu câu trả lời là không có gì cả, chẳng hạn một điểm trong mặt phẳng với hai toạ độ tự do, hãy dùng struct và để mọi thứ public. Bọc một struct toạ độ bằng bốn getter và bốn setter rỗng không làm mã an toàn hơn, chỉ làm nó dài hơn.
Bất biến của lớp
Bất biến (class invariant) là mệnh đề luôn đúng về trạng thái của đối tượng, kể từ lúc hàm khởi tạo chạy xong cho tới lúc hàm hủy bắt đầu. Ví dụ:
TaiKhoan:soDu >= 0.PhanSo: mẫu số khác 0, và phân số luôn ở dạng tối giản.NgayThang:thangnằm trong 1 tới 12,ngayhợp lệ với tháng đó.
Bên trong một phương thức, bạn được phép tạm thời phá bất biến, nhưng khi phương thức kết thúc thì bất biến phải nguyên vẹn trở lại. Người dùng lớp không bao giờ nhìn thấy trạng thái nửa vời. Vậy nên mỗi lần viết một lớp, hãy tự hỏi: "Mệnh đề nào phải luôn đúng cho đối tượng này?". Trả lời được câu đó, bạn biết ngay cần kiểm tra gì và kiểm tra ở đâu.
Lớp PhanSo dưới đây thiết lập cả hai bất biến ngay trong hàm khởi tạo.
#include <iostream>
#include <stdexcept>
using namespace std;
class PhanSo {
private:
int tu, mau; // invariant: mau != 0 and the fraction is reduced
void rutGon() { // private helper that restores the invariant
int a = tu < 0 ? -tu : tu, b = mau < 0 ? -mau : mau;
while (b != 0) { int r = a % b; a = b; b = r; } // gcd
int g = (a == 0) ? 1 : a;
tu /= g; mau /= g;
if (mau < 0) { tu = -tu; mau = -mau; } // keep the sign on top
}
public:
PhanSo(int t, int m) : tu(t), mau(m) {
if (m == 0) throw invalid_argument("mau so bang 0"); // reject a broken object at birth
rutGon();
}
void in() const { cout << tu << "/" << mau << "\n"; }
};
int main() {
PhanSo a(2, 4);
a.in(); // 1/2, reduced by the constructor itself
// PhanSo c(1, 0); // would throw: the object never exists in a broken state
}
Điểm mấu chốt: rutGon là private. Nó là công cụ nội bộ để lớp tự khôi phục bất biến,
không phải thứ người dùng được phép gọi sai lúc.
Bây giờ hãy nối bất biến với chuyện setter, vì đây là chỗ nhiều thiết kế đổ vỡ. Giả sử ta thêm vào PhanSo một setter vô điều kiện void datMau(int m) { mau = m; }. Chỉ một dòng, và toàn bộ công sức của hàm khởi tạo tan thành mây khói: gọi datMau(0) là bất biến mẫu số khác không bị phá, và mọi phép chia sau đó là hành vi không xác định. Điều then chốt: một setter vô điều kiện đặt lớp vào trạng thái mà không phương thức nào của lớp có thể tạo ra, và vì thế mọi lập luận bạn đã xây dựng về lớp đều mất hiệu lực.
Nguyên nhân gốc là setter chỉ thấy một thuộc tính, còn bất biến thì thường ràng buộc nhiều thuộc tính với nhau. Ngày là 31 chỉ hợp lệ nếu tháng không phải tháng 4; số dư giảm chỉ hợp lệ nếu có một giao dịch rút được ghi nhận. Một setter đơn lẻ không đủ ngữ cảnh để phán xét, nên nó chỉ có thể gán bừa. Kết luận thiết kế: khi bất biến trải trên nhiều trường, đừng mở setter cho từng trường, hãy mở một phương thức nghiệp vụ nhận đủ cả cụm và kiểm tra cả cụm cùng lúc, ví dụ datNgay(d, m, y) thay cho ba setter riêng.
Getter và setter cho đúng
Getter và setter là cặp phương thức công khai để đọc và ghi một thuộc tính ẩn. Vài quy tắc thực hành:
- Getter chỉ đọc nên phải khai báo
const:long xem() const { return soDu; }. Nhờ đó gọi được getter trên đối tượng hằng, và trình biên dịch chặn việc lỡ tay sửa dữ liệu. - Getter cho kiểu lớn (chuỗi, vector) nên trả về tham chiếu hằng để tránh sao chép:
const string& layTen() const { return ten; }. - Setter phải kiểm tra rồi mới gán. Setter không kiểm tra gì thì chẳng khác nào để thuộc
tính
public, chỉ dài dòng hơn. - Không phải thuộc tính nào cũng cần đủ cả cặp. Số dư tài khoản nên có getter nhưng tuyệt đối không nên có setter: người ta nạp tiền và rút tiền, không ai "đặt số dư".
void datSoDu(long s) { soDu = s; } cho phép bên ngoài đặt số dư về -999 y hệt như khi
soDu là public. Bạn đã gõ thêm ba dòng mà không mua được sự an toàn nào. Setter chỉ
có giá trị khi nó từ chối được đầu vào sai.
vector<int>& layDiem() { return diem; } trao thẳng quyền ghi vào dữ liệu private cho
bên ngoài: người ta viết hs.layDiem().push_back(-5) là bất biến vỡ ngay. Hãy trả về
const vector<int>& khi chỉ cho đọc, và mở một phương thức riêng như themDiem(int)
có kiểm tra khi cần ghi.
Nguyên nhân gốc đáng nói kỹ vì lỗi này rất hay lọt lưới khi đọc mã. Nhìn từ bên ngoài, layDiem mang mọi dấu hiệu của một getter ngoan hiền: nó chỉ đọc, nó không gán gì. Nhưng thứ nó trả về không phải một bản sao giá trị, mà là đường dẫn trực tiếp tới ô nhớ nằm trong đối tượng, và người nhận có toàn quyền ghi vào. Bạn đã dựng cổng, đặt lính gác, rồi tự tay đưa cho khách một chiếc chìa khoá cửa sau. Đây gọi là rò rỉ đóng gói, và nó vô hiệu hoá đúng cái lợi ích lập luận cục bộ đã bàn ở đầu bài: điểm âm giờ có thể phát sinh từ bất kỳ đâu trong dự án. Cùng cái bẫy ấy xuất hiện dưới nhiều lốt: trả về con trỏ tới thành viên riêng, trả iterator không hằng, hoặc bên Java là trả thẳng một mảng nội bộ. Ba lối thoát, chọn theo nhu cầu: trả bản sao khi dữ liệu nhỏ, trả tham chiếu hằng khi dữ liệu lớn mà người gọi chỉ cần đọc, hoặc chỉ mở vài phương thức nhỏ như soLuongDiem() và diemTaiViTri(i) nếu muốn giữ quyền đổi hẳn cách lưu trữ về sau.
Hậu tố const trong long xemSoDu() const nói rằng phương thức này không sửa trạng thái của đối tượng, và đó không phải trang trí: nếu bạn lỡ tay gán vào một thuộc tính bên trong phương thức const, trình biên dịch báo lỗi ngay. Một lời hứa được máy kiểm tra đáng giá hơn nhiều một lời hứa nằm trong bình luận.
Hệ quả thực tế mà nhiều người chỉ phát hiện khi đã muộn: nếu quên const, bạn không gọi được phương thức đó trên đối tượng hằng, cũng không gọi được qua tham chiếu hằng. Mà tham chiếu hằng lại chính là cách chuẩn để truyền đối tượng vào hàm mà không sao chép, nên một getter thiếu const sẽ lan lỗi ra khắp nơi: mọi hàm nhận const TaiKhoan& đều không dùng được nó. Quy tắc gọn: mọi phương thức không sửa trạng thái đều phải là const, không có ngoại lệ.
Giao diện tối thiểu
Mỗi khi định thêm một phương thức vào phần public, hãy tự hỏi: điều này có thực sự cần cho người dùng lớp không, hay tôi thêm chỉ vì nó có sẵn?
Nhiều người học theo lối máy móc, cứ mỗi thuộc tính private là gõ ngay một getter và một setter, coi đó là bổn phận. Kết quả là một lớp năm thuộc tính với mười phương thức công khai không làm gì ngoài đọc ghi thẳng. Lớp ấy chỉ là một struct mặc áo class: mọi trường đều ghi được từ bên ngoài, không bất biến nào được bảo vệ, chỉ có thêm mười dòng để đọc. Bạn đã trả cái giá của đóng gói mà không mua được lợi ích nào của nó.
Cách nghĩ đúng là đi từ nhu cầu của người dùng lớp, không phải từ danh sách thuộc tính. Người ta sẽ làm gì với một tài khoản? Nạp tiền, rút tiền, chuyển tiền, xem số dư; đó là bốn phương thức, và chúng chính là phần public. Không ai cần "đặt số dư", nên không có datSoDu. Giao diện phản ánh nghiệp vụ, không phản ánh cấu trúc bộ nhớ. Giao diện càng nhỏ thì lớp càng dễ học, dễ kiểm thử và dễ sửa, vì mỗi phương thức công khai là một cánh cửa mà bất biến có thể lọt qua, nên ít cửa thì ít chỗ phải canh. Đừng sợ mở thiếu: thêm một phương thức công khai về sau là chuyện dễ, còn gỡ bỏ một phương thức mà nửa dự án đã trót gọi mới là chuyện khó.
Ví dụ đầy đủ: tài khoản ngân hàng
Ghép mọi ý lại thành một lớp TaiKhoan dùng được thật. Bất biến: soDu >= 0 và chủ tài
khoản có tên không rỗng.
#include <iostream>
#include <string>
#include <stdexcept>
using namespace std;
class TaiKhoan {
private:
string chuTaiKhoan; // invariant: never empty
long soDu; // invariant: always >= 0
public:
TaiKhoan(const string& ten, long soDuDau = 0) {
if (ten.empty()) throw invalid_argument("ten rong");
if (soDuDau < 0) throw invalid_argument("so du dau am");
chuTaiKhoan = ten; // the object cannot exist in an invalid state
soDu = soDuDau;
}
void napTien(long t) {
if (t <= 0) throw invalid_argument("so tien nap phai duong");
soDu += t;
}
bool rutTien(long t) { // returns false instead of breaking the invariant
if (t <= 0 || t > soDu) return false;
soDu -= t;
return true;
}
bool chuyenTien(TaiKhoan& dich, long t) {
if (!rutTien(t)) return false; // reuse the checked withdrawal
dich.napTien(t); // a method may touch another object's private data
return true; // because both are of the same class
}
long xemSoDu() const { return soDu; } // read-only getter, const
const string& xemChu() const { return chuTaiKhoan; } // no copy of the string
// deliberately no datSoDu(): a balance is changed by depositing or withdrawing, never by assignment
};
int main() {
TaiKhoan an("An", 1000);
TaiKhoan binh("Binh");
an.chuyenTien(binh, 300);
cout << an.xemChu() << ": " << an.xemSoDu() << "\n"; // An: 700
cout << binh.xemChu() << ": " << binh.xemSoDu() << "\n"; // Binh: 300
cout << an.chuyenTien(binh, 999999) << "\n"; // 0: chan vi khong du so du
cout << an.xemSoDu() << "\n"; // 700: khong doi
}
Hãy để ý ba chi tiết. Một, hàm khởi tạo là cổng chặn đầu tiên: đối tượng không thể ra
đời ở trạng thái sai. Hai, chuyenTien dùng lại rutTien thay vì tự trừ, nên chỉ có duy
nhất một chỗ trong lớp biết cách trừ tiền. Ba, chuyenTien sửa được dữ liệu private của
đối tượng dich, vì private giới hạn theo lớp chứ không theo từng đối tượng.
Lỗi hay gặp và nguyên nhân gốc
Nhiều người viết if (t <= tk.xemSoDu()) tk.rutTien(t); ở main rồi yên tâm rằng số dư không thể âm. Nguyên nhân gốc của sai lầm này là nhầm giữa việc một chỗ gọi đúng với việc lớp luôn đúng. Kiểm tra đặt ngoài lớp chỉ bảo vệ đúng chỗ gọi đó; chỗ gọi thứ hai, do người khác viết ba tháng sau, sẽ không có kiểm tra ấy, và bất biến vỡ. Kiểm tra phải nằm bên trong lớp, ở đúng phương thức chạm vào dữ liệu, thì mới bảo vệ được mọi lối vào, kể cả những lối chưa được viết ra.
Lớp có setter kiểm tra rất cẩn thận, nhưng hàm khởi tạo lại gán thẳng tham số vào thuộc tính. Người dùng chỉ cần viết TaiKhoan tk("", -500); là đối tượng ra đời đã hỏng, và không setter nào cứu được nữa vì nó hỏng từ lúc sinh. Nguyên nhân gốc: người viết coi hàm khởi tạo là chỗ khởi tạo giá trị, chứ chưa coi nó là cổng chặn đầu tiên. Bất biến bắt đầu có hiệu lực từ giây phút hàm khởi tạo kết thúc, nên chính hàm khởi tạo phải thiết lập nó bằng cách từ chối đầu vào sai.
Bài tập thực hành
Bài tập 1: rút tiền không cho âm số dư
Bổ sung cho lớp TaiKhoan một phương thức rutTien(long t). Phương thức chỉ được trừ vào soDu khi số tiền rút hợp lệ (lớn hơn 0) và không vượt quá số dư hiện có. Nếu không hợp lệ thì giữ nguyên soDu. Viết main để thử rút quá số dư và rút hợp lệ, rồi in kết quả.
Lời giải chi tiết
Bất biến cần giữ: soDu không bao giờ âm. Vì soDu là private, mọi thay đổi đều phải đi qua phương thức công khai, nên ta đặt kiểm tra ngay trong rutTien. Có hai điều kiện: số tiền rút phải dương (chặn giá trị âm hoặc 0) và phải nhỏ hơn hoặc bằng soDu (chặn rút quá tay). Chỉ khi cả hai đúng mới trừ tiền.
#include <iostream>
using namespace std;
class TaiKhoan {
private:
long soDu = 0; // hidden state, invariant: soDu >= 0
public:
void napTien(long t) { if (t > 0) soDu += t; }
bool rutTien(long t) { // returns true if the withdrawal succeeds
if (t > 0 && t <= soDu) { // valid amount and enough balance
soDu -= t;
return true;
}
return false; // otherwise leave soDu untouched
}
long xem() { return soDu; }
};
int main() {
TaiKhoan tk;
tk.napTien(500);
cout << tk.rutTien(800) << "\n"; // 0: bị chặn vì rút quá số dư
cout << tk.rutTien(200) << "\n"; // 1: hợp lệ
cout << tk.xem() << "\n"; // 300
}
Ý chính: đặt điều kiện kiểm tra bên trong phương thức công khai là cách đóng gói bảo vệ bất biến, code bên ngoài không thể trừ thẳng làm số dư âm.
Bài tập 2: lớp NhietDo giới hạn khoảng
Viết lớp NhietDo lưu nhiệt độ (đơn vị độ C) trong thuộc tính private. Giá trị chỉ được phép nằm trong khoảng từ -50 đến 50. Cung cấp dat(double t) để gán (chỉ chấp nhận giá trị trong khoảng) và lay() để đọc. Khi giá trị bị từ chối, nhiệt độ giữ nguyên giá trị cũ. Khởi tạo mặc định là 0.
Lời giải chi tiết
Bất biến: giá trị nhiệt độ luôn nằm trong đoạn [-50, 50]. Ta giấu thuộc tính giaTri ở private và chỉ mở dat cùng lay. Trong dat, kiểm tra biên trên và biên dưới trước khi gán. Nếu giá trị mới nằm ngoài khoảng thì không gán gì cả, nhờ vậy giá trị cũ (vốn đã hợp lệ) được giữ lại, bất biến không bị phá.
#include <iostream>
using namespace std;
class NhietDo {
private:
double giaTri = 0; // invariant: -50 <= giaTri <= 50
public:
bool dat(double t) { // returns true if accepted
if (t >= -50 && t <= 50) { // range check before assigning
giaTri = t;
return true;
}
return false; // out of range, keep old value
}
double lay() { return giaTri; }
};
int main() {
NhietDo nd;
cout << nd.dat(25) << "\n"; // 1: hợp lệ
cout << nd.dat(100) << "\n"; // 0: ngoài khoảng, bị từ chối
cout << nd.lay() << "\n"; // 25: giữ giá trị cũ hợp lệ
}
Ý chính: setter có kiểm tra biên giúp đối tượng luôn ở trạng thái hợp lệ, kể cả khi nhận đầu vào sai.
Bài tập 3: getter và setter có kiểm tra cho lớp HocSinh
Viết lớp HocSinh với hai thuộc tính private: ten (chuỗi không rỗng) và diem (số thực trong khoảng 0 đến 10). Cung cấp setter datTen, datDiem có kiểm tra và getter layTen, layDiem. Setter trả về bool cho biết thao tác có được chấp nhận hay không. Giá trị khởi tạo: ten rỗng và diem bằng 0. Thử các trường hợp hợp lệ và không hợp lệ.
Lời giải chi tiết
Lớp này có hai bất biến độc lập: ten không rỗng (sau khi đã đặt thành công) và diem nằm trong [0, 10]. Mỗi setter chỉ chịu trách nhiệm cho một thuộc tính và tự kiểm tra điều kiện của riêng nó: datTen chặn chuỗi rỗng, datDiem chặn điểm ngoài khoảng. Getter chỉ đọc nên không cần kiểm tra. Nhờ tách riêng từng setter, việc kiểm soát rõ ràng và dễ mở rộng.
#include <iostream>
#include <string>
using namespace std;
class HocSinh {
private:
string ten = ""; // invariant: non-empty once set
double diem = 0; // invariant: 0 <= diem <= 10
public:
bool datTen(const string& t) { // reject empty name
if (!t.empty()) {
ten = t;
return true;
}
return false;
}
bool datDiem(double d) { // reject out-of-range score
if (d >= 0 && d <= 10) {
diem = d;
return true;
}
return false;
}
string layTen() { return ten; }
double layDiem() { return diem; }
};
int main() {
HocSinh hs;
cout << hs.datTen("An") << "\n"; // 1: hợp lệ
cout << hs.datTen("") << "\n"; // 0: bị chặn vì rỗng
cout << hs.datDiem(8.5) << "\n"; // 1: hợp lệ
cout << hs.datDiem(15) << "\n"; // 0: bị chặn vì ngoài khoảng
cout << hs.layTen() << " " << hs.layDiem() << "\n"; // An 8.5
}
Ý chính: mỗi getter/setter quản một thuộc tính và tự giữ bất biến của thuộc tính đó, giúp giao diện công khai vừa an toàn vừa dễ bảo trì.
Bài tập 4: lớp NgayThang giữ bất biến ngày hợp lệ
Viết lớp NgayThang với ba thuộc tính private: ngay, thang, nam. Bất biến: thang từ 1 tới 12, nam lớn hơn 0, và ngay không vượt quá số ngày của tháng đó (bỏ qua năm nhuận, tháng 2 có 28 ngày). Hàm khởi tạo từ chối ngày sai bằng cách ném invalid_argument. Thêm hopLe dạng static để kiểm tra trước một bộ ba số, và in() const để in ra.
Lời giải chi tiết
Chỗ khó là số ngày trong tháng phụ thuộc vào chính thang, nên không thể kiểm tra ngay một cách rời rạc. Ta tra bảng số ngày rồi so ngay với giới hạn của tháng tương ứng. Logic này được dùng ở hai nơi (người gọi muốn thử trước, hàm khởi tạo muốn chặn), nên đặt nó vào một phương thức static dùng chung để không viết trùng.
Kiểm tra nằm trong hàm khởi tạo nên đối tượng không bao giờ ra đời ở trạng thái sai, và vì lớp không có setter nào, bất biến còn nguyên suốt vòng đời.
#include <iostream>
#include <stdexcept>
using namespace std;
class NgayThang {
private:
int ngay, thang, nam; // invariant: a valid date (leap years ignored)
public:
static bool hopLe(int d, int m, int y) { // reusable check
static const int bang[12] = {31,28,31,30,31,30,31,31,30,31,30,31};
if (y <= 0 || m < 1 || m > 12) return false;
return d >= 1 && d <= bang[m - 1]; // day limit depends on the month
}
NgayThang(int d, int m, int y) {
if (!hopLe(d, m, y)) throw invalid_argument("ngay khong hop le");
ngay = d; thang = m; nam = y; // reached only when the invariant holds
}
void in() const { cout << ngay << "/" << thang << "/" << nam << "\n"; }
};
int main() {
cout << NgayThang::hopLe(31, 4, 2026) << "\n"; // 0: thang 4 chi co 30 ngay
NgayThang d(14, 7, 2026);
d.in(); // 14/7/2026
try {
NgayThang xau(31, 2, 2026); // constructor rejects it
} catch (const invalid_argument& e) {
cout << "Bi chan: " << e.what() << "\n";
}
}
Ý chính: khi bất biến ràng buộc nhiều thuộc tính với nhau, hãy kiểm tra cả bộ trong hàm khởi tạo, vì ngay chỉ có nghĩa khi đã biết thang.
Bài tập 5: bịt lỗ rò rỉ đóng gói
Lớp BangDiem dưới đây trông rất đúng chuẩn: thuộc tính private, phương thức themDiem có kiểm tra biên. Nhưng bất biến của nó vẫn bị phá được từ main mà không cần đụng tới thuộc tính. Hãy chỉ ra chỗ thủng, giải thích nguyên nhân gốc, rồi viết lại lớp cho kín.
class BangDiem {
private:
std::vector<double> diem; // invariant: every score is in [0, 10]
public:
bool themDiem(double d) {
if (d < 0 || d > 10) return false;
diem.push_back(d);
return true;
}
std::vector<double>& layDiem() { return diem; } // where is the hole?
};
Lời giải chi tiết
Chỗ thủng là layDiem. Nó trả về một tham chiếu không hằng tới chính cái vector nằm bên trong đối tượng, nên người gọi nhận được quyền ghi trực tiếp: chỉ cần bd.layDiem().push_back(-99); là một điểm âm chui vào bảng mà themDiem không hề được gọi. Kiểm tra biên trong themDiem trở nên vô nghĩa, vì nó không còn là con đường duy nhất để dữ liệu đi vào.
Nguyên nhân gốc là nhầm giữa "chỉ đọc" theo nghĩa phương thức không sửa gì với "chỉ đọc" theo nghĩa thứ trả về không sửa được. layDiem đúng là không tự tay sửa diem, nhưng nó trao đi một tham chiếu có quyền ghi, nên hậu quả y hệt như để diem ở public. Đóng gói thủng ở đầu ra chứ không phải đầu vào.
Bản vá làm ba việc: trả về tham chiếu hằng để cấm ghi mà vẫn không sao chép, gắn const cho mọi phương thức chỉ đọc, và mở thêm vài phương thức nhỏ để người dùng làm được việc mà không cần cầm cả vector.
#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;
class BangDiem {
private:
vector<double> diem; // invariant: every score lies in [0, 10]
public:
bool themDiem(double d) { // the only way a score gets in
if (d < 0 || d > 10) return false;
diem.push_back(d);
return true;
}
const vector<double>& xemDiem() const { // read-only view: no copy, no write access
return diem;
}
size_t soLuong() const { return diem.size(); }
double taiViTri(size_t i) const { // bounds checked here, not by the caller
if (i >= diem.size()) throw out_of_range("chi so ngoai khoang");
return diem[i];
}
double trungBinh() const { // a business method, not a raw data dump
if (diem.empty()) return 0;
double tong = 0;
for (double d : diem) tong += d;
return tong / diem.size();
}
};
int main() {
BangDiem bd;
cout << bd.themDiem(8.5) << "\n"; // 1: hop le
cout << bd.themDiem(-99) << "\n"; // 0: bi chan
cout << bd.themDiem(9.5) << "\n"; // 1: hop le
// bd.xemDiem().push_back(-99); // loi bien dich: vector la const, khong ghi duoc
cout << bd.soLuong() << "\n"; // 2
cout << bd.trungBinh() << "\n"; // 9
}
Ý chính: đóng gói chỉ kín khi cả đầu vào lẫn đầu ra đều được canh. Mỗi lần định trả về tham chiếu hay con trỏ tới một thành viên riêng, hãy tự hỏi người nhận có ghi vào được không; nếu có, bạn vừa mở một cửa sau. Chú ý thêm rằng trungBinh minh hoạ cho giao diện tối thiểu: người dùng cần con số trung bình chứ không cần cả bảng điểm, nên cứ đưa họ đúng con số đó.
Tự kiểm tra
Viết tk.soDu = -1; trong main được không?
Không, soDu là private nên dòng đó gây lỗi biên dịch. Đây chính là điều
đóng gói bảo vệ: không cho code ngoài đặt số dư về giá trị vô lý.
Câu hỏi tự kiểm
- 1Mục đích thật sự của private là gì?
- 2Vì sao một setter vô điều kiện như void datMau(int m) { mau = m; } lại phá hỏng lớp PhanSo?
- 3Getter trả về tham chiếu không hằng tới vector nội bộ (kiểu vector T tham chiếu, không const) rò rỉ đóng gói vì sao?
- 4Hậu tố const trong long xemSoDu() const bảo đảm điều gì?
Tóm tắt
- Đóng gói: dữ liệu
private, giao diệnpubliccó kiểm soát. Mặc định đểprivate, chỉ nới lỏng khi có lý do cụ thể. - Bất biến là mệnh đề luôn đúng về đối tượng. Hàm khởi tạo thiết lập nó, mỗi phương thức công khai phải giữ nó.
- Kiểm tra đặt trong hàm khởi tạo và trong setter. Sai thì từ chối, không gán bừa.
- Getter chỉ đọc thì thêm
const, trả tham chiếu hằng với kiểu lớn. - Lợi ích lớn nhất không phải là "an toàn" chung chung, mà là bạn được đổi cách cài đặt bên trong mà không làm hỏng code của người khác.
privatekhông chống được kẻ xấu, nó thu hẹp vùng phải soi khi có sai sót: nghi phạm chỉ còn một lớp thay vì cả dự án.- Setter vô điều kiện phá bất biến vì nó chỉ thấy một trường, trong khi bất biến thường ràng buộc nhiều trường. Khi đó hãy mở một phương thức nghiệp vụ nhận cả cụm.
- Getter và setter không phải nghĩa vụ; chỉ mở đúng cái người dùng lớp thật sự cần.
- Trả tham chiếu không hằng tới thành viên riêng là rò rỉ đóng gói, dù tên phương thức nghe rất vô hại.
structcho cụm dữ liệu không có bất biến,classcho khi có mệnh đề phải luôn đúng.
Đóng gói = private dữ liệu + public giao diện kiểm soát. Lợi ích: dữ liệu luôn hợp lệ, và có thể đổi cách lưu trữ bên trong mà không ảnh hưởng code dùng nó.