⚡C++ 对象模型与多态

继承访问控制与对象切片

面试回答

常见问法

  • publicprotectedprivate 继承到底区别在哪?
  • 为什么说 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 继承之后,基类成员“没了”。 这不对。成员还在,只是访问权限映射变了

假设基类中有 publicprotected 成员:

class Base {
public:
    void pub() {}
protected:
    void pro() {}
private:
    int x = 0;
};

如果派生方式不同,映射关系如下:

  • public 继承:

    • Base::public -> 在派生类中仍然是 public
    • Base::protected -> 在派生类中仍然是 protected
  • protected 继承:

    • Base::public -> 变成 protected
    • Base::protected -> 仍然 protected
  • private 继承:

    • Base::public -> 变成 private
    • Base::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 是一种 Animal
  • Circle 是一种 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. 为什么多态必须配合引用/指针使用

多态的关键不是“有虚函数”就够了,而是:

  1. 基类函数是 virtual
  2. 通过基类接口访问
  3. 底层对象的动态类型仍然是派生类

引用和指针不会复制对象,所以不会切片:

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>

避免:

  • Base
  • std::vector<Base>

建议四:多态基类应有虚析构函数

否则通过 Base* 删除 Derived 会产生未定义行为。

struct Base {
    virtual ~Base() = default;
};

这虽然不是“对象切片”本身,但面试时常被顺手追问,最好主动补一句。


对比总结

概念核心语义外部能否把派生类当基类用典型用途优点缺点工程建议
public 继承is-a可以多态接口、抽象层次语义清晰,支持替换原则设计不当会导致基类耦合过强只在“真的是一种”时使用
protected 继承对子类开放、对外隐藏不可以直接作为公共基类用很少见的层次内部复用可在派生层次内部继续传播接口可读性差,使用场景少除非明确需要,否则少用
private 继承基于其实现不可以实现复用、EBO、重写钩子可访问 protected,有时节省空间语义不直观,容易滥用默认不优先,组合通常更好
组合has-a不涉及复用实现、委托低耦合、易维护、语义清楚不能直接访问 protected,不能天然 override默认优先选组合

私有继承 vs 组合

维度私有继承组合
语义based-on-implementationhas-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,最适合多态;protectedprivate 继承更多是实现复用,不强调对外替换。 对象切片是指派生类对象按值赋给基类对象,或者按值传给基类参数时,只有基类子对象会被保留,派生类新增部分会丢失,所以多态也会失效。 因此工程上只要期望多态,就应该通过基类引用、指针或智能指针来使用,而不是按值传递或存储基类对象。


面试加分版

这题其实有两个点:继承访问控制对象切片

先说继承方式。publicprotectedprivate 继承,不是说基类成员有没有了,而是说基类接口在派生类对外看来是什么权限public 继承里,基类的 public 仍然是 public,所以外部可以把派生类当基类用,这就是 is-a,也是多态最常见的用法; protectedprivate 继承则更偏向实现复用,尤其 private 继承,本质上更像“用基类来实现自己”,而不是“自己是一种基类”。所以工程里如果只是想复用能力,通常优先考虑组合,除非你需要访问 protected、重写虚函数,或者利用空基类优化。

再说对象切片。它发生在按值语义下,比如 Base b = Derived{}void f(Base b) 或者 vector<Base>Derived。 本质不是“虚函数突然不灵了”,而是已经新构造出了一个独立的 Base 对象,派生类那部分数据和行为都不存在了,所以动态类型就是 Base,调用虚函数自然只能走基类版本。

所以这题最完整的落点是: 真 is-a 关系才用 public 继承;只是复用实现优先用组合;只要你想保留多态,就必须通过基类引用、指针或智能指针来使用对象,避免按值导致切片。