前言
public、protected、private是 C++ 中最先接触、也最容易被低估的三个关键字。多数教材只给一句「public 谁都能访问,private 只有自己能访问」,然后就进入写代码的环节。结果就是:有人把所有成员都设成 public,把类写成「带成员函数的结构体」;有人以为private是运行期的安全保护;还有人搞不清protected和继承方式组合后到底谁能看见谁。
先把几个前提讲清楚。第一,访问控制是编译期的概念,它只约束「名字能不能被使用」,不改变对象布局,也不产生运行期检查;private不是加密,指针偏移、内存拷贝照样能绕过,只是那样做属于未定义行为。第二,class和struct在 C++ 里几乎完全等价,唯一差别是默认访问级别与默认继承方式。第三,继承方式(public/protected/private inheritance)和成员访问级别是两个维度,必须放在一起看。
本文按「类定义 → 成员访问 → 继承矩阵 → 工程实践」的顺序展开,把三个关键字在每种组合下的效果讲透。示例以 C++17 为基准,涉及编译错误的行会用注释标出。
一、class 与 struct 的唯一差别
在 C++ 中,class和struct都能定义成员、成员函数、构造函数、继承、虚函数,功能上没有区别。差别只有两条:
| 项目 | class | struct |
|---|
| 默认成员访问级别 | private | public |
| 默认继承方式 | private继承 | public继承 |
也就是说,下面两段代码完全等价:
// 适用于 C++17
class A {
int x; // 默认 private
};
struct B {
int x; // 默认 public
};社区约定是:只聚合数据、没有不变式(invariant)的类型用struct;需要维护不变式、有封装边界的类型用class。标准库也遵循这个约定,std::pair、std::array是struct,std::vector、std::string是class。约定不是语法要求,但它能让读代码的人一眼判断「这个类型的内部状态是否可以被随意改」。
访问说明符可以出现任意多次,作用范围是从它出现的位置到下一个访问说明符或类定义结束:
// 适用于 C++17
class Widget {
public:
void draw();
protected:
int id_;
private:
int cached_;
public:
int id() const { return id_; } // 又回到 public
};这种写法合法,但可读性差;通常按public→protected→private的顺序各写一次。
二、三种访问级别的真实边界
2.1 public
public成员对所有人可见:类自己的成员函数、派生类、外部代码、友元。它是类的接口面。设计类时先想清楚「哪些操作是契约」,把这些放 public,其余一律收起来。
2.2 private
private成员只有以下位置可以访问:
- 该类自己的成员函数(包括静态成员函数);
- 该类的友元函数与友元类;
- 该类的嵌套类(C++11 起嵌套类可以访问外层类的私有成员)。
派生类不能访问基类的 private 成员,无论用哪种继承方式。这一点常被误解为「继承了就看得见」,实际上继承只影响布局和名字查找的起点,不改变访问权限。
需要强调:private是一种「接口约束」,不是安全机制。通过reinterpret_cast把对象当成字节数组、或者用#define private public这种技巧,都能绕过它,但前者在严格别名规则下是未定义行为,后者是未定义行为的另一种形式(改变标准库头的语义)。正确做法是修改设计,而不是绕过访问控制。
2.3 protected
protected的可见范围介于两者之间:
- 类自己的成员函数可以访问;
- 派生类的成员函数可以访问;
- 友元可以访问;
- 外部代码不可以访问。
但protected有一个非常容易被忽略的限制:派生类只能通过「派生类类型(或更派生的类型)」的对象访问基类的 protected 成员,不能通过基类对象访问。例如:
// 适用于 C++17
class Base {
protected:
int value_ = 0;
};
class Derived : public Base {
public:
void ok() {
value_ = 1; // OK:通过 this 访问
Derived d;
d.value_ = 2; // OK:通过 Derived 对象访问
}
void bad(Base& b) {
// b.value_ = 3; // 错误:不能通过 Base 对象访问
}
};这条规则的存在,是为了防止派生类随意修改「任意一个基类对象」的受保护状态。理解它,才能解释为什么有些代码在派生类里明明「继承了」却编译不过。
protected的争议在于:它把内部状态暴露给所有未来的派生类,形成一个比 public 更脆弱的契约——派生类可能依赖某个 protected 成员的实现细节,基类一改就全崩。Scott Meyers 早就建议「优先用 private,把需要给派生类的东西做成 protected 成员函数而不是数据成员」。现代设计更倾向于「要么 public 接口,要么 private 实现」,protected 数据成员应尽量避免。
三、继承方式与访问级别矩阵
继承方式决定了基类成员在派生类中的「最高可见级别」。把三种继承方式和三种成员级别组合起来,得到九种情况:
| 基类成员 \ 继承方式 | public继承 | protected继承 | private继承 |
|---|
public成员 | 派生类中为 public | 派生类中为 protected | 派生类中为 private |
protected成员 | 派生类中为 protected | 派生类中为 protected | 派生类中为 private |
private成员 | 不可访问 | 不可访问 | 不可访问 |
规律可以概括为:派生类中的访问级别 =min(成员原级别, 继承方式),其中 private < protected < public;基类的 private 成员永远是「不可访问」,与继承方式无关。
private继承在语义上是「用基类的实现,但不暴露基类的接口」,常用于实现继承(如基于std::vector实现一个栈)。它和「成员对象」的区别在于能否利用空基类优化、能否重写虚函数。多数场景下,组合(把基类作为成员)比 private 继承更清晰。
protected继承更少见,它让基类的 public 接口对派生类的派生类可见,但对外部不可见。真正需要它的场景很少,遇到时先确认是否可以用组合替代。
C++ 还允许在派生类中用using调整某个名字的访问级别:
// 适用于 C++17
class Base {
protected:
void helper();
};
class Derived : private Base {
public:
using Base::helper; // 把 helper 提升为 public
};using只能改变访问级别,不能改变名字本身;它也不能把基类的 private 成员变可见。
四、访问控制与虚函数、友元、嵌套类
4.1 private 虚函数也能被重写
虚函数的调用走动态分派,与访问级别无关。基类可以把虚函数设为 private,派生类照样能重写,通过基类指针调用时也照样分派到派生类实现:
// 适用于 C++17
class Shape {
public:
virtual ~Shape() = default;
void render() { doRender(); } // 模板方法:对外只暴露 render
private:
virtual void doRender() = 0; // 派生类可以重写私有虚函数
};
class Circle : public Shape {
private:
void doRender() override { }
};这是模板方法模式(template method)的常见写法:基类用 public 非虚函数固定流程,把可变的步骤放进 private 虚函数。派生类不需要、也不应该直接调用doRender。
4.2 友元
friend声明可以授予某个函数或类访问本类 private/protected 成员的权限。要点:
- 友元关系不传递:A 是 B 的友元、B 是 C 的友元,不代表 A 能访问 C;
- 友元关系不继承:基类的友元不能访问派生类的私有成员,反之亦然;
- 友元声明写在类内,但友元函数本身是命名空间作用域的普通函数,不是成员函数;
friend class X;授予 X 的所有成员函数访问权,范围较大,优先授予单个函数。
友元常被当作「破坏封装」的坏东西,其实它是受控的开口:类的作者主动决定「谁可以看我的内部」,比把成员改成 public 要好得多。典型合法用途是运算符重载(operator<<)和两个紧密协作的类。
4.3 嵌套类
C++11 起,嵌套类可以访问外层类的所有成员(包括 private),但外层类不能访问嵌套类的 private 成员。嵌套类与外层类之间没有隐式的this关联,它只是一个定义在类作用域内的普通类,访问外层非静态成员仍需一个外层对象。
常见坑点
- ❌ 以为
private成员在派生类里「继承下来了就能用」。✅ 基类 private 成员在派生类中始终不可访问,只能通过基类提供的 public/protected 接口间接操作。
- ❌ 在派生类里通过基类引用访问 protected 成员:
void f(Base& b) { b.x = 1; }。✅ 只能通过派生类类型(或更派生类型)的对象访问;改成参数类型为Derived&,或让基类提供 protected 成员函数。
- ❌ 用
#define private public来「测试私有成员」。✅ 这是未定义行为,且会污染所有包含的头文件;测试应通过 public 接口,或把待测逻辑抽成可单独测试的组件。
- ❌ 把
protected数据成员当作给派生类的「扩展点」。✅ 优先提供 protected 成员函数或 public 虚函数;数据成员保持 private,避免派生类绑定实现细节。
- ❌ 认为
class默认继承是 public。✅class D : B是 private 继承,struct D : B才是 public 继承;写继承时显式写出继承方式。
- ❌ 以为友元关系会被派生类继承。✅ 友元不继承、不传递;需要时在派生类中重新声明。
- ❌ 把
using Base::foo;当成「复制」了函数。✅ 它只是把基类的名字引入派生类作用域并调整可见性,函数实体仍在基类。
- ❌ 用
private继承后又想让外部通过基类指针使用派生对象。✅ private 继承下基类子对象对外不可见,Base* p = new Derived;在类外不合法;需要「is-a」关系就用 public 继承,需要复用实现就用组合。
总结
| 访问位置 | public 成员 | protected 成员 | private 成员 |
|---|
| 本类成员函数 | 可访问 | 可访问 | 可访问 |
| 派生类成员函数 | 可访问 | 可访问 | 不可访问 |
| 友元 | 可访问 | 可访问 | 可访问 |
| 外部代码 | 可访问 | 不可访问 | 不可访问 |
| 继承方式 | 对外表现 | 典型用途 |
|---|
| public | 派生类 is-a 基类 | 接口继承、多态 |
| protected | 只对进一步派生开放 | 极少使用 |
| private | 基类接口完全隐藏 | 实现复用(多数情况应用组合) |
三个访问关键字的核心,不是「谁能看」,而是「作者愿意承诺什么」。public 是承诺,protected 是有限承诺,private 是不承诺。把承诺面收得越小,类就越容易演进;把所有成员都设成 public,等于放弃了未来修改实现的自由。理解继承矩阵和 protected 的访问限制,能避免大部分「明明继承了却编译不过」的困惑。