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

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 để soDupublic. 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 soDuprivate 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 publicprivate. Ở đâ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ừpublicprotectedprivate
Trong chính lớp
Trong lớp conkhông
Từ bên ngoài (main)khôngkhông
Chọn mức nào?

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

Ai được chạm vào thành viên nào 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}
Ngăn xếp stack
main()
#1QuanLy
ten (public)"Lan"
luong (protected)9000000
maPin (private)1234
Bộ nhớ động heap
(trống)
Tạo QuanLy q. Vì QuanLy kế thừa NhanVien, q chứa cả ba trường với ba mức truy cập khác nhau: ten (public), luong (protected), maPin (private).
1/5

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 protected dạng getter, đừng hạ dữ liệu xuống.
  • Lớp con cần thay đổi thì mở một hàm protected có 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.

Nguyên nhân gốc: protected khóa lớp cha vào lớp con

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ộ raB 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
publicvẫn publicthành protectedthành private
protectedvẫn protectedvẫn protectedthành private
privatekhông thấykhông thấykhô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 public diễn đạt quan hệ is-a. XeMay là một Dong, nên ở đâu cần Dong thì đưa XeMay và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 private diễn đạt quan hệ cài đặt bằng. MayBom không phải là một Dong, nó chỉ dùng Dong bên trong. Trong hầu hết trường hợp như vậy, giữ một thành viên Dong dong; (thành phần, composition) còn rõ ràng hơn kế thừa private.

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 privateprotected 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.

Lỗi thường gặp
  • Quên public khi kế thừa. Viết class Dog : Animal thì mặc định là kế thừa private, nên Animal* 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ới struct thì mặc định lại là public. Hãy luôn viết rõ : public Animal.
  • Tưởng lớp con thấy private củ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 public cho 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 protected vì thấy lớp con cần. Hãy mở một hàm protected thay 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 BB 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

  1. Bắt đầu bằng cách để mọi thứprivate.
  2. Mở lên public chỉ 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.
  3. Nếu lớp được thiết kế để kế thừa, mở thêm các hàm protected mà lớp con cần; dữ liệu vẫn ở private.
  4. 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 friend thay vì public.
  5. Với kế thừa, mặc định luôn là : public. Chỉ nghĩ tới private khi 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()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ữ tenprivate 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()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

Kiểm tra nhanh: quyền truy cập và kế thừa0/4 đúngchưa trả lời
  1. 1Lớp con có truy cập trực tiếp thành viên private của lớp cha không?
  2. 2Viết class Dog : Animal (không ghi từ khóa mức kế thừa) thì điều gì xảy ra?
  3. 3Quan hệ friend trong C++ có các tính chất nào?
  4. 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

  • public mở cho mọi nơi, private chỉ trong chính lớp, protected cho 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 ở private kể cả khi có kế thừa; muốn cho lớp con dùng thì mở một hàm protected có 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; private của cha thì lớp con luôn không thấy.
  • : public là quan hệ is-a và là điều kiện để dùng đa hình; : private là quan hệ cài đặt bằng, thường nên thay bằng composition.
  • friend là 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.
Ghi nhớ

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.