Quyền truy cập: public, private, protected
Hãy nghĩ về một máy rút tiền tự động. Người dùng chỉ chạm được vào màn hình và bàn phím. Bên trong có két sắt, bo mạch, nhật ký giao dịch, không ai được đụng. Nhân viên bảo trì thì có chìa khóa mở nắp sau, tiếp cận được vài bộ phận mà người dùng không thấy. Ba nhóm quyền ấy chính là bức tranh của ba mức truy cập trong C++.
Mỗi thành viên của lớp, dù là dữ liệu hay phương thức, đều nằm dưới một mức truy cập quy
định ai được phép chạm vào nó. Đây không phải chuyện an ninh mà là kỷ luật thiết kế:
ta cố tình thu hẹp bề mặt mà bên ngoài có thể dựa vào, để sau này còn tự do sửa phần bên
trong. Giả sử bạn để soDu là public. Ba tháng sau, cả trăm chỗ trong dự án đã viết
tk.soDu = tk.soDu - x. Giờ sếp yêu cầu mọi thay đổi số dư phải ghi nhật ký và không được
âm. Bạn không còn chỗ nào để chèn kiểm tra vào, vì phép gán trực tiếp không đi qua mã của
bạn. Nếu ngay từ đầu soDu là private và mọi người chỉ gọi tk.rutTien(x), bạn chỉ phải
sửa một hàm.
Bài đóng gói đã dùng public và private. Ở đây ta làm rõ cả ba mức, đặc biệt là
protected (chỉ có ý nghĩa khi đã có lớp con), và một khái niệm hay bị hiểu nhầm: kiểu kế
thừa, thứ quyết định các thành viên của lớp cha lộ ra ở lớp con dưới mức nào.
Ba mức truy cập
public: mọi nơi đều dùng được (trong chính lớp, trong lớp con, và từ bên ngoài).private: chỉ bên trong chính lớp đó. Ngay cả lớp con cũng không thấy.protected: bên trong lớp đó và các lớp con, nhưng bên ngoài thì không.
| Truy cập từ | public | protected | private |
|---|---|---|---|
| Trong chính lớp | có | có | có |
| Trong lớp con | có | có | không |
Từ bên ngoài (main) | có | không | không |
Mặc định nên để dữ liệu là private (đóng gói chặt). Mở lên protected chỉ khi lớp
con thật sự cần dùng trực tiếp. Để public cho giao diện (các phương thức ta
muốn người ngoài gọi). Càng ít công khai, lớp càng dễ bảo trì.
Mã nguồn
#include <iostream>
#include <string>
using namespace std;
class NhanVien {
public:
string ten; // public: ai cũng đọc được
protected:
long luong; // protected: lớp con dùng được, ngoài thì không
private:
int maPin; // private: chỉ NhanVien dùng
public:
NhanVien(string t, long l, int p) : ten(t), luong(l), maPin(p) {}
};
class QuanLy : public NhanVien {
public:
QuanLy(string t, long l, int p) : NhanVien(t, l, p) {}
long thuong() { return luong / 10; } // OK: protected dùng được trong lớp con
};
int main() {
QuanLy q("Lan", 9000000, 1234);
cout << q.ten << " "; // OK: ten là public
cout << q.thuong(); // 900000
// cout << q.luong; // LỖI: protected, không gọi được từ ngoài
// cout << q.maPin; // LỖI: private, không gọi được từ ngoài
}
Thử ngay: xem quyền truy cập chạy từng bước
1class NhanVien {2public:3 string ten;4protected:5 long luong;6private:7 int maPin;8public:9 NhanVien(string t, long l, int p)10 : ten(t), luong(l), maPin(p) {}11};12class QuanLy : public NhanVien {13public:14 QuanLy(string t, long l, int p) : NhanVien(t, l, p) {}15 long thuong() { return luong / 10; }16};17int main() {18 QuanLy q("Lan", 9000000, 1234);19 cout << q.ten << " ";20 cout << q.thuong();21}
Hiểu cho đúng: quyền truy cập gắn với LỚP, không gắn với ĐỐI TƯỢNG
Một chi tiết làm nhiều người bất ngờ: private chặn theo lớp, không theo đối tượng.
Bên trong một phương thức của lớp TienTe, ta được phép đọc phần private của bất kỳ đối
tượng TienTe nào khác, không riêng this. Nhờ vậy mới viết được
TienTe cong(const TienTe& other) const { return TienTe(xu + other.xu); }, trong đó other.xu
là một trường riêng tư của đối tượng khác. Nếu quy tắc không như vậy, ta không viết nổi những
thứ cơ bản nhất như hàm so sánh hai đối tượng hay hàm dựng sao chép.
protected trong kế thừa
protected là lời hứa của lớp cha với lớp con: phần này tôi cho các anh dùng, người ngoài
thì không.
Nhưng protected không vô hại. Một khi đặt trường dữ liệu ở protected, mọi lớp con (kể cả
lớp con do người khác viết sau này) đều có thể sửa trực tiếp trường đó, và lớp cha mất
khả năng giữ các ràng buộc của chính mình. Nói cách khác, protected biến dữ liệu thành một
phần giao diện với họ hàng của lớp. Lời khuyên thực hành:
- Dữ liệu: cứ để
private, kể cả khi có kế thừa. - Lớp con cần đọc thì mở một hàm
protecteddạng getter, đừng hạ dữ liệu xuống. - Lớp con cần thay đổi thì mở một hàm
protectedcó kiểm tra, để lớp cha vẫn giữ được ràng buộc của mình (ví dụ số dư không âm).
Lý do sâu hơn nằm ở chỗ protected biến ràng buộc của lớp cha thành trách nhiệm chung. Khi
trường dữ liệu còn private, chỉ mã trong lớp cha động vào nó, nên mọi bất biến (số dư không
âm, mảng luôn sắp xếp, con trỏ luôn hợp lệ) đều được canh giữ tại một chỗ. Hạ trường xuống
protected là trao chìa khóa ấy cho mọi lớp con, kể cả những lớp chưa ai viết. Từ lúc đó, mỗi
lần lớp cha muốn đổi cách biểu diễn dữ liệu, nó phải lo rằng đâu đó một lớp con đang đọc thẳng
vào trường cũ. Đây chính là bài toán lớp cơ sở mong manh: một sửa đổi tưởng như nội bộ của
lớp cha lại làm hỏng những lớp con ở rất xa, mà trình biên dịch không hề cảnh báo.
Một trường protected không chỉ nới quyền, nó còn đóng băng cách biểu diễn. Bất kỳ lớp con
nào cũng có thể phụ thuộc vào đúng cái tên và kiểu của trường đó, nên lớp cha đánh mất tự do đổi
phần ruột mà không báo trước. Ngược lại, một hàm protected chỉ hứa một hành vi, còn cách lưu
trữ bên dưới lớp cha vẫn được tự do thay. Khi phân vân, hãy nhớ: mở hành vi thì an toàn, mở dữ
liệu thì mắc nợ.
#include <iostream>
using namespace std;
class TaiKhoan {
private:
long soDu; // still fully private
protected:
// Controlled mutation point for subclasses: the invariant stays here.
bool truTien(long x) {
if (x <= 0 || x > soDu) return false; // the parent keeps its invariant
soDu -= x;
return true;
}
long laySoDu() const { return soDu; } // controlled read for subclasses
public:
TaiKhoan(long sd) : soDu(sd) {}
long xemSoDu() const { return soDu; }
};
class TaiKhoanTraTruoc : public TaiKhoan {
public:
TaiKhoanTraTruoc(long sd) : TaiKhoan(sd) {}
void muaThe(long gia) {
// soDu -= gia; // error: soDu is private in the base
if (truTien(gia)) { // OK: protected helper of the base
cout << "mua the thanh cong, con " << laySoDu() << "\n";
} else {
cout << "khong du tien\n";
}
}
};
int main() {
TaiKhoanTraTruoc t(50000);
t.muaThe(20000); // mua the thanh cong, con 30000
t.muaThe(90000); // khong du tien
return 0;
}
Hãy so sánh với phương án hạ soDu xuống protected: khi đó lớp con có thể viết
soDu -= 90000 và làm số dư âm, lớp cha không còn cách nào ngăn. Câu "mở protected cho
tiện" thường là một khoản nợ kỹ thuật.
Kiểu kế thừa: public, protected, private
Trong class B : public A, từ khóa public không nói về việc B có thấy các thành viên
của A hay không. Nó nói về việc các thành viên của A lộ ra ở B dưới mức nào đối với
người ngoài. Đây là điểm hầu như ai cũng hiểu nhầm lúc đầu.
| Thành viên ở lớp cha | : public A | : protected A | : private A |
|---|---|---|---|
public | vẫn public | thành protected | thành private |
protected | vẫn protected | vẫn protected | thành private |
private | không thấy | không thấy | không thấy |
Đọc bảng này ta thấy: dù kiểu kế thừa là gì, thành viên private của lớp cha luôn khuất
với lớp con. Và kiểu kế thừa chỉ có tác dụng hạ mức xuống, không bao giờ nâng lên.
#include <iostream>
using namespace std;
class Dong {
public:
void chay() { cout << "dong co chay\n"; }
};
class XeMay : public Dong {}; // is-a: a XeMay IS A Dong
class MayBom : private Dong { // has-a: a MayBom USES a Dong internally
public:
void bom() {
chay(); // OK: inside the class we still see chay()
cout << "dang bom nuoc\n";
}
};
int main() {
XeMay x;
x.chay(); // OK: public inheritance keeps chay() public
MayBom m;
// m.chay(); // error: private inheritance made chay() private in MayBom
m.bom(); // the only door MayBom opens
return 0;
}
Ý nghĩa thiết kế của hai kiểu này rất khác nhau:
- Kế thừa
publicdiễn đạt quan hệ is-a.XeMaylà mộtDong, nên ở đâu cầnDongthì đưaXeMayvào được. Đây là kiểu bạn dùng gần như mọi lúc, và là kiểu duy nhất cho phép con trỏ lớp cha trỏ tới đối tượng lớp con, tức nền tảng của đa hình. - Kế thừa
privatediễn đạt quan hệ cài đặt bằng.MayBomkhông phải là mộtDong, nó chỉ dùngDongbên trong. Trong hầu hết trường hợp như vậy, giữ một thành viênDong dong;(thành phần, composition) còn rõ ràng hơn kế thừaprivate.
Một hệ quả tinh tế của việc kiểu kế thừa chỉ hạ mức: bạn không thể dùng nó để giấu bớt vài
phương thức mà vẫn giữ được quan hệ is-a. Kế thừa private khóa tất cả cùng một lúc, đồng thời
cắt luôn khả năng chuyển ngầm Con* thành Cha*. Nếu sau khi kế thừa private mà vẫn muốn lộ
lại đúng một phương thức của lớp cha ra ngoài, ta viết một khai báo using Dong::chay; trong
vùng public của lớp con, chọn lại từng cái tên chứ không có cách nào nâng mức cho cả nhóm.
Điều này nhắc rằng kế thừa private và protected là công cụ tái sử dụng cài đặt, không phải
công cụ tỉa gọt giao diện; khi nhu cầu thật sự là tỉa giao diện, composition gần như luôn là
lựa chọn sạch hơn.
- Quên
publickhi kế thừa. Viếtclass Dog : Animalthì mặc định là kế thừaprivate, nênAnimal* a = new Dog();không biên dịch được, và thông báo lỗi thường rất khó hiểu. Vớistructthì mặc định lại làpublic. Hãy luôn viết rõ: public Animal. - Tưởng lớp con thấy
privatecủa cha. Không thấy. Đây là lỗi số một khi mới học kế thừa. - Nhét mọi thứ vào
publiccho khỏi vướng. Hôm nay biên dịch chạy ngon, ba tháng sau bạn không còn quyền đổi bất cứ thứ gì bên trong. - Hạ dữ liệu xuống
protectedvì thấy lớp con cần. Hãy mở một hàmprotectedthay vì hạ trường dữ liệu.
friend: cánh cửa mở có chủ đích
Đôi khi một hàm bên ngoài thật sự cần nhìn vào ruột lớp ta, mà mở public cho cả thế giới
thì quá đắt. C++ cho phép lớp chỉ định đích danh ai được vào, bằng friend. Trường hợp
kinh điển là nạp chồng toán tử xuất ra luồng: hàm này không thể là phương thức thành viên (vì
toán hạng trái là ostream), nhưng lại cần đọc dữ liệu riêng tư.
#include <iostream>
using namespace std;
class Diem {
private:
int x, y;
public:
Diem(int x, int y) : x(x), y(y) {}
// Grant access to exactly one function, nothing more.
friend ostream& operator<<(ostream& os, const Diem& d);
};
ostream& operator<<(ostream& os, const Diem& d) {
os << "(" << d.x << ", " << d.y << ")"; // may read private members
return os;
}
int main() {
Diem p(3, 4);
cout << p << "\n"; // (3, 4)
return 0;
}
Hai điều cần nhớ. Thứ nhất, quan hệ bạn bè là một chiều và không di truyền: bạn của cha
không tự động là bạn của con. Thứ hai, friend không phá vỡ đóng gói mà là một phần của đóng
gói, vì chính lớp chủ động ghi ra danh sách ai được vào. Dù vậy, mỗi friend là thêm một chỗ
phụ thuộc vào chi tiết bên trong, nên hãy dùng tiết kiệm.
Còn một tính chất nữa dễ quên: bạn bè không bắc cầu. Nếu A kết bạn B và B kết bạn
C, thì C vẫn không nhìn thấy phần riêng tư của A; mỗi quan hệ bạn phải được lớp chủ ghi
ra tận nơi, không có chuyện thừa hưởng qua trung gian. Tính không đối xứng cũng vậy: A cho
B làm bạn không có nghĩa A được nhìn vào ruột của B, muốn thế thì B phải kết bạn A
riêng. Ba đặc điểm ấy, một chiều, không di truyền, không bắc cầu, chính là thứ giữ cho friend
vẫn nằm trong tầm kiểm soát: toàn bộ danh sách ai được vào luôn nằm gọn trong khai báo của lớp,
đọc một chỗ là biết hết, không có cửa hậu mở ngầm qua họ hàng hay qua bạn của bạn.
Chọn mức nào: một quy trình gọn
- Bắt đầu bằng cách để mọi thứ là
private. - Mở lên
publicchỉ những phương thức mà người dùng lớp thật sự cần gọi. Đó là hợp đồng bạn phải giữ rất lâu. - Nếu lớp được thiết kế để kế thừa, mở thêm các hàm
protectedmà lớp con cần; dữ liệu vẫn ởprivate. - Nếu còn đúng một hàm bên ngoài cần nhìn vào bên trong, cân nhắc
friendthay vìpublic. - Với kế thừa, mặc định luôn là
: public. Chỉ nghĩ tớiprivatekhi thật sự muốn dùng lại cài đặt mà không nhận giao diện, và ngay cả khi đó hãy xét composition trước.
Bài tập thực hành
Bài tập 1: đặt đúng mức truy cập
Cho lớp TaiKhoanNganHang với: số tài khoản (người ngoài được xem), số dư (chỉ lớp
này quản lý), mã PIN (tuyệt đối riêng tư). Hãy khai báo ba thành viên này với mức truy
cập phù hợp, kèm một phương thức public để xem số dư.
Lời giải chi tiết
Quy tắc đặt mức: dữ liệu nhạy cảm để riêng tư nhất có thể, chỉ mở ra qua phương thức.
Số tài khoản có thể cho xem nên đặt public (hoặc giữ private và mở getter). Số dư
chỉ lớp tự quản nên private. Mã PIN tuyệt mật nên private. Phương thức xemSoDu()
là public để người ngoài đọc số dư một cách có kiểm soát.
#include <iostream>
#include <string>
using namespace std;
class TaiKhoanNganHang {
public:
string soTaiKhoan; // visible to everyone
private:
long soDu; // only this class manages it
int maPin; // strictly secret
public:
TaiKhoanNganHang(string stk, long sd, int pin)
: soTaiKhoan(stk), soDu(sd), maPin(pin) {}
long xemSoDu() const { return soDu; } // controlled public access
};
int main() {
TaiKhoanNganHang tk("0123", 5000000, 9999);
cout << tk.soTaiKhoan << " " << tk.xemSoDu();
// cout << tk.soDu; // error: private
// cout << tk.maPin; // error: private
}
Càng nhiều dữ liệu để private, lớp càng an toàn; chỉ mở ra qua phương thức public.
Bài tập 2: protected cho lớp con, private thì không
Viết lớp cha Hinh với một thành viên protected double dvi (đơn vị đo) và một thành
viên private int maBiMat. Viết lớp con HinhTron : public Hinh có phương thức cố gắng
dùng dvi. Giải thích vì sao dùng dvi được nhưng nếu dùng maBiMat sẽ lỗi.
Lời giải chi tiết
Lớp con thấy được thành viên protected của cha nhưng không thấy private. Vì vậy
HinhTron dùng dvi (protected) thoải mái, còn maBiMat (private) thì không, dù
HinhTron đã kế thừa từ Hinh. Dòng dùng maBiMat bị chú thích lại để chương trình
biên dịch được; nếu mở ra sẽ báo lỗi truy cập.
#include <iostream>
using namespace std;
class Hinh {
protected:
double dvi; // visible to derived classes
private:
int maBiMat; // not visible to derived classes
public:
Hinh(double d, int m) : dvi(d), maBiMat(m) {}
};
class HinhTron : public Hinh {
public:
HinhTron(double d, int m) : Hinh(d, m) {}
double doDvi() {
return dvi; // OK: protected member of the base
// return maBiMat; // error: private member of the base
}
};
int main() {
HinhTron t(2.5, 777);
cout << t.doDvi(); // 2.5
}
protected là cánh cửa cha mở riêng cho con, còn private thì đóng với cả con.
Bài tập 3: từ private nâng lên protected khi cần
Bạn có lớp DongVat với private string ten và một lớp con Cho cần in ra tên trong
phương thức của nó. Hiện tại Cho không truy cập được ten. Hãy đề xuất và thực hiện
cách sửa hợp lý, kèm lý do.
Lời giải chi tiết
Có hai cách. Cách 1: đổi ten từ private thành protected để lớp con dùng trực
tiếp. Cách 2 (thường tốt hơn): giữ ten là private và cung cấp một phương thức
protected hoặc public như layTen() để lớp con đọc qua giao diện. Cách 2 giữ đóng
gói chặt hơn vì lớp cha vẫn kiểm soát hoàn toàn ten. Dưới đây minh họa cách 2.
#include <iostream>
#include <string>
using namespace std;
class DongVat {
private:
string ten; // stays private
protected:
string layTen() const { return ten; } // controlled access for subclasses
public:
DongVat(string t) : ten(t) {}
};
class Cho : public DongVat {
public:
Cho(string t) : DongVat(t) {}
void sua() { cout << layTen() << ": Gau gau"; } // read name via accessor
};
int main() {
Cho c("Mun");
c.sua(); // Mun: Gau gau
}
Ưu tiên mở một phương thức truy cập thay vì hạ thẳng dữ liệu xuống protected, để lớp
cha vẫn nắm quyền kiểm soát.
Bài tập 4: kế thừa public hay private
Cho lớp NganXep (ngăn xếp) cần dùng lại một lớp DanhSach đã có sẵn với các phương thức
themCuoi, xoaCuoi, xoaDau, cuoi. Yêu cầu: ngăn xếp chỉ được cho người dùng gọi day()
và lay(), tuyệt đối không cho gọi xoaDau() (vì như vậy sẽ không còn là ngăn xếp nữa).
Hãy chọn kiểu kế thừa phù hợp và giải thích.
Lời giải chi tiết
Phải dùng kế thừa private. Nếu kế thừa public, mọi phương thức public của DanhSach
(kể cả xoaDau) sẽ lộ ra ở NganXep và người dùng có thể phá vỡ ngữ nghĩa vào trước ra sau.
Kế thừa private hạ toàn bộ giao diện của DanhSach xuống private trong NganXep: bên
trong lớp ta vẫn gọi được, bên ngoài thì không. Xét ngữ nghĩa cũng đúng, vì ngăn xếp không
phải là một danh sách, nó chỉ được cài đặt bằng một danh sách.
#include <iostream>
#include <vector>
using namespace std;
class DanhSach {
public:
void themCuoi(int x) { v.push_back(x); }
void xoaCuoi() { if (!v.empty()) v.pop_back(); }
void xoaDau() { if (!v.empty()) v.erase(v.begin()); }
int cuoi() const { return v.back(); }
private:
vector<int> v;
};
class NganXep : private DanhSach { // implemented in terms of DanhSach
public:
void day(int x) { themCuoi(x); } // OK inside the class
int lay() { int x = cuoi(); xoaCuoi(); return x; }
};
int main() {
NganXep s;
s.day(1);
s.day(2);
// s.xoaDau(); // error: private inheritance hides it. Exactly what we want.
cout << s.lay() << "\n"; // 2 (last in, first out)
return 0;
}
Ý chính: kiểu kế thừa quyết định giao diện nào lộ ra ngoài. Trong dự án thật, giữ một
thành viên DanhSach ds; (composition) thường còn rõ hơn và nên được ưu tiên.
Tự kiểm tra
Lớp con có truy cập được thành viên private của lớp cha không?
Không. private chỉ giới hạn trong chính lớp khai báo nó, lớp con cũng bị chặn.
Muốn lớp con dùng được, hãy để thành viên đó là protected, hoặc cung cấp một phương
thức (protected/public) để lớp con gọi.
Nếu không ghi mức truy cập nào, mặc định là gì?
Với class, mặc định là private. Với struct, mặc định là public. Đó là khác
biệt duy nhất giữa hai từ khoá, và là lý do ta dùng class rồi ghi rõ public: cho
phần giao diện.
Viết class Dog : Animal (không ghi public) thì sao?
Với class, kiểu kế thừa mặc định là private. Mọi thành viên public của Animal trở
thành private trong Dog, và quan trọng nhất là không còn chuyển đổi ngầm từ Dog*
sang Animal*, nên Animal* a = new Dog(); báo lỗi biên dịch. Luôn viết rõ : public Animal.
Câu hỏi tự kiểm
- 1Lớp con có truy cập trực tiếp thành viên private của lớp cha không?
- 2Viết class Dog : Animal (không ghi từ khóa mức kế thừa) thì điều gì xảy ra?
- 3Quan hệ friend trong C++ có các tính chất nào?
- 4Vì sao nên mở một hàm protected thay vì hạ trường dữ liệu xuống protected cho lớp con dùng?
Tóm tắt
publicmở cho mọi nơi,privatechỉ trong chính lớp,protectedcho chính lớp và lớp con.- Quyền truy cập gắn với lớp, không gắn với đối tượng.
- Dữ liệu nên ở
privatekể cả khi có kế thừa; muốn cho lớp con dùng thì mở một hàmprotectedcó kiểm soát. - Kiểu kế thừa chỉ hạ mức các thành viên của lớp cha khi chúng lộ ra ở lớp con, không bao
giờ mở thêm quyền;
privatecủa cha thì lớp con luôn không thấy. : publiclà quan hệ is-a và là điều kiện để dùng đa hình;: privatelà quan hệ cài đặt bằng, thường nên thay bằng composition.friendlà cánh cửa mở đích danh do chính lớp quyết định, không di truyền, không đối xứng.
public mở cho mọi nơi, private đóng với tất cả trừ chính lớp, protected mở cho
chính lớp và lớp con. Mặc định để dữ liệu private, chỉ nới lên protected hay
public khi có lý do rõ ràng.