继承访问控制与对象切片
面试回答
常见问法
public、protected、private继承到底区别在哪?- 为什么说
public继承才表示真正的 is-a? private继承和组合(composition)怎么选?- 什么是对象切片(object slicing)?
- 为什么对象切片会让多态失效?
- 为什么多态一般都通过基类指针或引用使用,而不是按值传参?
回答
先说结论:继承方式决定的是“基类成员在派生类对外暴露成什么访问级别”,以及“派生类对象能不能在外部被当成基类来用”。
public继承:表示 is-a,也就是“派生类就是一种基类”,最符合面向对象建模,也最适合做多态接口。protected继承:很少用于对外建模,更多是“对子类层次开放、对外部隐藏”的实现复用。private继承:更像“基于基类实现细节来实现自己”,强调实现复用,不强调对外是基类。
对象切片是另外一个高频考点。当派生类对象按值赋给基类对象,或者按值传参给基类参数时,只会保留其中的基类子对象,派生类新增的那部分会被切掉。 一旦切片发生,新的对象已经是一个独立的“纯基类对象”了,所以动态绑定依赖的“真实派生对象”不复存在,多态自然也就失效了。
#include <iostream>
using namespace std;
struct Base {
virtual void f() { cout << "Base\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
void f() override { cout << "Derived\n"; }
int extra = 42;
};
int main() {
Base b = Derived{}; // 对象切片
b.f(); // Base
}
这道题面试时最好补一句判断原则: 只要你期待多态,就不要按值存放、按值传递基类对象,而应该使用基类引用、指针,或者智能指针。
追问
1)为什么 public 继承才最适合多态?
因为多态的前提之一是:在外部可以把派生类对象当成基类使用。
只有 public 继承,外部才允许把 Derived* / Derived& 隐式转换成 Base* / Base&。这才符合“里氏替换原则”:凡是用基类的地方,都应该能透明替换成派生类。
2)protected / private 继承是不是没用?
不是没用,只是不像 public 继承那样常见。
它们主要用于“复用实现”,而不是表达对象模型。实际工程里,大多数这类需求更推荐组合,只有在需要:
- 访问基类的
protected成员 - 重写虚函数但不想暴露 is-a 关系
- 利用空基类优化(EBO) 时,才会考虑私有继承。
3)切片为什么会让动态绑定失效?
因为动态绑定依赖的是“对象的动态类型”。
Base b = Derived{}; 这句不是让 b 引用那个 Derived,而是重新构造了一个 Base 对象。
既然 b 本身已经不再是 Derived,那虚函数调用只能按 Base 的动态类型执行。
4)哪些场景最容易不小心发生切片?
- 基类按值传参
- 基类按值返回
- 容器里存
vector<Base>,却往里放Derived - 赋值
Base b = d;
void call(Base b) { // 按值,可能切片
b.f();
}
void call_ref(const Base& b) { // 不切片,保留多态
b.f();
}
原理展开
1. 继承访问控制:控制的是“对外可见性”,不是“成员消失”
很多人一开始会误以为:private 继承之后,基类成员“没了”。
这不对。成员还在,只是访问权限映射变了。
假设基类中有 public 和 protected 成员:
class Base {
public:
void pub() {}
protected:
void pro() {}
private:
int x = 0;
};
如果派生方式不同,映射关系如下:
-
public继承:Base::public-> 在派生类中仍然是publicBase::protected-> 在派生类中仍然是protected
-
protected继承:Base::public-> 变成protectedBase::protected-> 仍然protected
-
private继承:Base::public-> 变成privateBase::protected-> 变成private
无论哪种继承方式,基类的 private 成员都不能被派生类直接访问。
它们存在于对象内存布局中,但语义上只能通过基类提供的接口间接操作。
class Derived : private Base {
public:
void test() {
pub(); // 可以,映射成 private,但类内可访问
pro(); // 可以
}
};
int main() {
Derived d;
// d.pub(); // 错:对外不可访问
}
这里要抓住面试核心点: 继承方式本质上是在调整“基类接口对派生类外部世界的可见性”。
2. public 继承:表达 is-a,最符合面向对象建模
public 继承语义最强,表示“派生类就是一种基类”。
例如:
Dog是一种AnimalCircle是一种Shape
因此它最适合:
- 抽象接口
- 多态调用
- 框架回调
- 面向接口编程
struct Shape {
virtual double area() const = 0;
virtual ~Shape() = default;
};
struct Circle : public Shape {
double r;
Circle(double rr) : r(rr) {}
double area() const override { return 3.14 * r * r; }
};
这里 Circle 放到 Shape*、Shape& 里是合理的,因为它确实满足“任何需要 Shape 的地方,都能用 Circle 替代”。
面试里再加一句会更完整: 是否应该用继承,不是看代码能不能复用,而是看语义上是否真的是一种关系。 如果只是“用到了某个类的能力”,通常更适合组合,而不是继承。
3. protected / private 继承:偏实现复用,不强调外部替换性
这两种继承最大的特点是:外部不能把派生类自由当成基类来用。
protected 继承
更像“这个基类能力只想在派生层次内传播,不想对普通用户暴露”。
使用场景很少见,但可能出现在:
- 做中间层基类
- 只希望后续子类继续复用接口
- 不希望业务代码把当前类当成基类使用
private 继承
更常被拿来和组合对比。它表示:
- 当前类是“基于某个类实现的”
- 但当前类不是那个类的一种
例如:
class Timer {
public:
void start() {}
void stop() {}
};
class Widget : private Timer {
public:
void draw() {
start(); // 复用 Timer 的实现
}
};
这里 Widget 不是一种 Timer,只是“内部借用了 Timer 的能力”。
但工程上通常更推荐组合:
class Widget {
private:
Timer timer;
public:
void draw() {
timer.start();
}
};
因为组合更直观,也更符合“has-a”关系。
4. 私有继承和组合怎么选?
这是面试里很容易被追问的一点。
优先选组合的原因
组合通常更好,因为它:
- 语义更清晰:
has-a - 耦合更低
- 可替换性更强
- 更容易测试和维护
- 不会引入不必要的继承层级
私有继承少数适合的情况
只有在下面几种场景,私有继承才可能优于组合:
场景一:需要访问基类的 protected 成员
组合拿不到 protected,但私有继承可以。
场景二:需要重写基类虚函数,但不想暴露 is-a 关系
如果你只是想利用一个框架基类的虚函数钩子机制,而不希望用户把你当成那个基类来用,私有继承可能合理。
场景三:利用空基类优化(EBO)
当基类是空类时,私有继承有机会不占额外空间,而组合成员通常至少要占 1 字节。
struct Empty {};
struct A : private Empty { // 可能 1 字节
int x;
};
struct B {
Empty e; // 成员对象通常占位
int x;
};
但注意:现代 C++ 里如果只是为了省空间,很多场景也会用 [[no_unique_address]]。
面试回答时可总结为: 默认组合优先;只有在“需要 protected / override / EBO”这类继承特性本身有价值时,才考虑私有继承。
5. 什么是对象切片:本质是“基类值对象只保存基类那一部分”
对象切片最容易被误解成“虚函数失效”。 其实更本质的说法是:
对象切片不是调用阶段出问题,而是在构造阶段就已经丢失了派生部分。
看这个例子:
struct Base {
int a = 1;
virtual void f() { cout << "Base\n"; }
virtual ~Base() = default;
};
struct Derived : Base {
int b = 2;
void f() override { cout << "Derived\n"; }
};
Derived d;
Base b = d; // 切片
此时 b 里只有 Base 那部分:
a被复制了b不存在- 动态类型就是
Base
所以再调用 b.f(),自然就是 Base::f()。
6. 对象切片发生的典型场景
场景一:按值赋值
Derived d;
Base b = d; // 切片
场景二:按值传参
void process(Base b); // 调用时传 Derived 会切片
场景三:按值返回基类
Base make() {
return Derived{}; // 返回时构造成 Base,切片
}
场景四:容器按值存储基类对象
std::vector<Base> v;
v.push_back(Derived{}); // 切片
这是工程里非常常见的隐藏 bug。 如果想保留多态,一般会改成:
std::vector<std::unique_ptr<Base>> v;
v.push_back(std::make_unique<Derived>());
7. 为什么多态必须配合引用/指针使用
多态的关键不是“有虚函数”就够了,而是:
- 基类函数是
virtual - 通过基类接口访问
- 底层对象的动态类型仍然是派生类
引用和指针不会复制对象,所以不会切片:
void call(const Base& b) {
b.f(); // 保留动态类型
}
void call2(const Base* b) {
b->f(); // 保留动态类型
}
这也是为什么面试里经常会听到一句规范答案: 虚函数实现多态,但真正使用多态时,通常通过基类引用或指针,而不是基类值对象。
8. 工程实践中的设计建议
建议一:接口类尽量用 public 继承
如果你的设计目标是扩展点、插件机制、抽象接口,那就明确使用 public 继承。
建议二:不是 is-a,就不要为了复用代码强上继承
“能继承”不代表“该继承”。 如果只是想复用某些成员函数,组合通常更稳妥。
建议三:避免基类按值传递
如果类层次支持多态,接口设计时应优先:
Base&const Base&Base*std::unique_ptr<Base>std::shared_ptr<Base>
避免:
Basestd::vector<Base>
建议四:多态基类应有虚析构函数
否则通过 Base* 删除 Derived 会产生未定义行为。
struct Base {
virtual ~Base() = default;
};
这虽然不是“对象切片”本身,但面试时常被顺手追问,最好主动补一句。
对比总结
| 概念 | 核心语义 | 外部能否把派生类当基类用 | 典型用途 | 优点 | 缺点 | 工程建议 |
|---|---|---|---|---|---|---|
public 继承 | is-a | 可以 | 多态接口、抽象层次 | 语义清晰,支持替换原则 | 设计不当会导致基类耦合过强 | 只在“真的是一种”时使用 |
protected 继承 | 对子类开放、对外隐藏 | 不可以直接作为公共基类用 | 很少见的层次内部复用 | 可在派生层次内部继续传播接口 | 可读性差,使用场景少 | 除非明确需要,否则少用 |
private 继承 | 基于其实现 | 不可以 | 实现复用、EBO、重写钩子 | 可访问 protected,有时节省空间 | 语义不直观,容易滥用 | 默认不优先,组合通常更好 |
| 组合 | has-a | 不涉及 | 复用实现、委托 | 低耦合、易维护、语义清楚 | 不能直接访问 protected,不能天然 override | 默认优先选组合 |
私有继承 vs 组合
| 维度 | 私有继承 | 组合 |
|---|---|---|
| 语义 | based-on-implementation | has-a |
| 是否暴露基类接口 | 不对外暴露 | 完全由自己决定暴露什么 |
是否能访问基类 protected | 可以 | 不可以 |
| 是否能重写基类虚函数 | 可以 | 不可以 |
| 是否可能有 EBO | 可以 | 通常不行 |
| 可维护性 | 一般 | 更好 |
| 默认推荐 | 否 | 是 |
切片前后对比
| 场景 | 是否切片 | 是否保留多态 | 说明 |
|---|---|---|---|
Base b = Derived{}; | 是 | 否 | 生成了新的 Base 值对象 |
void f(Base b) | 是 | 否 | 按值传参发生复制 |
void f(const Base& b) | 否 | 是 | 引用不复制对象 |
Base* p = new Derived; | 否 | 是 | 指针指向真实派生对象 |
vector<Base> 存 Derived | 是 | 否 | 容器元素是值语义 |
vector<unique_ptr<Base>> 存 Derived | 否 | 是 | 容器中保存多态对象句柄 |
易错点
- 把“继承方式”和“成员原本的访问权限”混为一谈。 继承方式改变的是映射后的可见性,不是基类里成员声明本身。
- 误以为
private继承后,基类成员就不存在了。 实际上成员还在,只是外部不可见。 - 认为“有虚函数就一定有多态”。 错。还必须通过指针或引用访问真实派生对象。
- 不知道对象切片只发生在按值语义下。
- 忽略
vector<Base>的切片风险。 - 把私有继承当成“组合的高级写法”。 大多数情况下这是反的:组合才是默认设计。
- 多态基类忘记写虚析构函数。
- 回答“
public继承表示 is-a”时,只说结论,不补“为什么是外部可替换”。
记忆技巧
-
一句话记继承方式:
public:对外还是基类,能当基类用protected:只在派生层次里继续用private:只拿来内部实现复用
-
一句话记切片:
- 派生类一旦按值变成基类,派生那半截就没了。
-
一句话记多态:
- 虚函数决定“可多态”,引用/指针决定“真多态”。
-
设计口诀:
- 真 is-a 用公有继承;只是想复用,先想组合。
面试速答版
继承方式影响的是基类成员在派生类对外呈现成什么访问级别,以及派生类能不能在外部被当成基类使用。
public 继承表示真正的 is-a,最适合多态;protected 和 private 继承更多是实现复用,不强调对外替换。
对象切片是指派生类对象按值赋给基类对象,或者按值传给基类参数时,只有基类子对象会被保留,派生类新增部分会丢失,所以多态也会失效。
因此工程上只要期望多态,就应该通过基类引用、指针或智能指针来使用,而不是按值传递或存储基类对象。
面试加分版
这题其实有两个点:继承访问控制和对象切片。
先说继承方式。public、protected、private 继承,不是说基类成员有没有了,而是说基类接口在派生类对外看来是什么权限。
public 继承里,基类的 public 仍然是 public,所以外部可以把派生类当基类用,这就是 is-a,也是多态最常见的用法;
protected 和 private 继承则更偏向实现复用,尤其 private 继承,本质上更像“用基类来实现自己”,而不是“自己是一种基类”。所以工程里如果只是想复用能力,通常优先考虑组合,除非你需要访问 protected、重写虚函数,或者利用空基类优化。
再说对象切片。它发生在按值语义下,比如 Base b = Derived{}、void f(Base b) 或者 vector<Base> 存 Derived。
本质不是“虚函数突然不灵了”,而是已经新构造出了一个独立的 Base 对象,派生类那部分数据和行为都不存在了,所以动态类型就是 Base,调用虚函数自然只能走基类版本。
所以这题最完整的落点是:
真 is-a 关系才用 public 继承;只是复用实现优先用组合;只要你想保留多态,就必须通过基类引用、指针或智能指针来使用对象,避免按值导致切片。