一文详解 C++ 继承体系

发布于:2025-08-10 ⋅ 阅读:(16) ⋅ 点赞:(0)

继承是 C++ 面向对象的核心机制之一,用来复用代码、表达“is-a”关系并支持运行时多态(virtual functions)。但 C++ 的继承体系很强大也很复杂:有访问控制(public/protected/private)、单继承/多继承、虚继承、虚函数表(vptr/vtable)、对象切片(slicing)、构造/析构顺序、name hiding、以及模板技巧(CRTP)等。弄清这些细节能避免常见的 BUG 和运行时错误。

优惠:https://secret-thanks.com/bi3/VX0.PC3Bp_vyb/mSVLJjZBDI0z2mMWDbQlwINQDLAd3LL/TTYdwhN/DbA-0IM_DJgi

基本语法与访问控制

struct Base {
public:
    int x;
protected:
    int p;
private:
    int q;
    virtual void vf() {}
};

struct Derived : public Base {
    // public 继承:Base 的 public -> public, protected -> protected
};

struct Derived2 : protected Base {
    // protected 继承:Base 的 public/protected -> protected 在 Derived2 中
};

struct Derived3 : private Base {
    // private 继承:Base 的 public/protected -> private 在 Derived3 中
};
  • public 继承:表示 Derived is-a Base,允许外部把 Derived* 隐式转换为 Base*,常用的“接口继承”方式。
  • protected/private 继承:更像是“实现继承 / 用于实现”的手段(“implemented-in-terms-of”),外部不能隐式把 Derived 转为 Base(或受限制)。极少用于 API 设计,常用于库内部实现细节。
  • struct 默认继承/成员访问是 publicclass 默认是 private

成员继承与名字隐藏(name hiding)

  • 派生类继承基类的非 private 成员(数据、函数、类型别名等),但访问控制仍然受继承类型影响。
  • 名字隐藏:如果派生类定义了与基类同名函数(即使签名不同),基类中同名的所有重载都会被隐藏。要引入基类的重载,使用 using
struct Base { void f(int); void f(double); };
struct Derived : Base {
    using Base::f; // 引入基类的 f 重载集合
    void f(char);  // 新增一个重载
};

构造 / 析构 顺序(重要)

构造顺序(most-derived -> bases? careful!):

  1. 虚基类(virtual bases)先被构造(只构造一次),顺序按照最派生类继承声明的某个规则(通常是从左到右和声明顺序,细节由标准定义)。
  2. 然后按派生类 base-specifier-list 中从左到右顺序构造非虚基类
  3. 再构造派生类的成员(按在类中声明的顺序)。
  4. 最后进入派生类构造函数体(body)。

析构顺序与之相反(派生析构体先运行,然后成员,然后 base,最后虚 base)。

注意:对于虚继承,最派生类负责调用虚基类的构造函数并传参(即虚基由 most-derived 初始化)。

对象布局、vptr/vtable、对象切片

  • 为了实现动态绑定(virtual functions),编译器通常在每个多态(含虚函数)的对象中放一个指向该类型 vtable 的指针(称为 vptr)。vtable 存放函数指针用于动态调用。
  • 多重/虚继承时,可能存在多个 vptr 或额外的指针(如 vbptr)用于基类偏移调整,具体实现依编译器而异。
  • 对象切片(slicing):把派生类对象按值赋给基类对象会丢失派生部分:
struct Base { virtual ~Base() = default; int b; };
struct Derived : Base { int d; };

Derived dd; Base b = dd; // slicing:b 不包含 d

因此要传递多态对象应使用指针或引用(Base*/Base& 或 智能指针)。

虚函数与运行时多态

struct Base {
    virtual void foo() { std::cout << "Base\n"; }
    virtual ~Base() = default; // 若要通过 Base* 删除派生对象,必须 virtual
};

struct Derived : Base {
    void foo() override { std::cout << "Derived\n"; }
};
  • virtual 声明使得调用 ptr->foo() 在运行时按对象的动态类型选择实现(动态绑定)。
  • override(C++11)用于标注:编译器会检查该函数确实覆盖了基类的虚函数(避免写错签名后非覆盖导致的问题)。
  • final 可以阻止进一步重写:void foo() final;
  • 虚析构:如基类打算作为多态基类,必须有虚析构函数,否则通过 Base* 删除 Derived* 会导致未定义行为(派生析构未被调用)。

纯虚函数与抽象类(interface)

struct Interface {
    virtual void f() = 0; // 纯虚函数
    virtual ~Interface() = default;
};
  • 有纯虚函数的类是抽象类,不能实例化。
  • 抽象类常用作接口(纯虚 + 无数据成员)。
  • 纯虚函数也可以有实现(少见),但仍使类抽象:virtual void f() = 0; 在类外定义 void Interface::f(){...}

协变返回(Covariant return types)

派生类重写虚函数时允许返回协变类型(返回指针或引用时):

struct Base { virtual Base* clone() const; };
struct Derived : Base { Derived* clone() const override; } // 合法:Derived* 是 Base* 的协变类型

多重继承(multiple inheritance)

C++ 允许从多个基类派生:

struct A { int a; virtual ~A()=default; };
struct B : A {};
struct C : A {};
struct D : B, C {}; // D 中存在两个 A 子对象(如果不是虚继承)
  • 多重继承带来二义性D d; d.a; 会编译错误(不确定访问哪个 A::a),必须显式 d.B::ad.C::a
  • 如果基类都是多态(含 virtual),并想要共享同一个基类子对象(避免重复),则用 虚继承(virtual base)。

虚继承(diamond / 菱形问题)

经典菱形:

   A
  / \
 B   C
  \ /
   D

如果 B、C 都继承自 A,而 D 又从 B、C 继承,默认会有两个 A 子对象。若想在 D 中只保留一个 A 子对象,B 和 C 应该虚继承 A:

struct A { int a; };
struct B : virtual A {};
struct C : virtual A {};
struct D : B, C {}; // 只有一个 A 子对象,由 D 初始化
  • 初始化:最派生类 D 负责初始化虚基 A(传递构造参数)。B、C 不再单独构造 A。
  • 代价:虚继承会引入额外的实现复杂度(编译器可能增加偏移表或指针来在运行时找到虚基),导致对象体积或访问成本上升。具体代价依实现而异(但通常是存在的)。

超低价esim流量卡:https://www.wanmoon.mom/redteago-esim/

指针/引用转换、dynamic_cast 与 RTTI

  • static_cast<T*>:在继承链上做编译时转换(不做运行时检查)。
  • dynamic_cast<T*>:在多态基类上做安全向下转换(需要包含虚函数以启用 RTTI)。如果失败,返回 nullptr(指针情况);引用失败抛 std::bad_cast
Base* b = ...;
Derived* d = dynamic_cast<Derived*>(b);
if (d) { /* 成功 */ }
  • typeid:检查对象的动态类型(多态类型时返回动态类型),需注意 typeid(*ptr) 要保证 ptr 非空。

名称查找 / overload resolution 的细微点

  • 名称查找先在派生类进行,若找到对应名称则基类中同名会被隐藏(无论签名),接着在该名字对应的可用集合中做重载解析。
  • 解决隐藏常用方法:using Base::f; 或者显式 Base::f() 调用。

空基优化(EBO:Empty Base Optimization)

  • 如果基类是空类(没有非静态数据成员),编译器通常会把它作为空子对象并不增加派生对象的大小(EBO),这在如 std::tuple 等模板库里很常见,用以节省空间。
  • 但如果派生类有多个同类型的空基,标准允许但具体细节和符号链接(type identity)有关;一般情况下 EBO 会被使用。

模板技巧:CRTP(静态多态)

CRTP(Curiously Recurring Template Pattern)是一种静态多态替代运行时虚函数的技巧:

template<typename Derived>
struct BaseCRTP {
    void interface() { static_cast<Derived*>(this)->implementation(); }
};

struct Impl : BaseCRTP<Impl> {
    void implementation() { /* ... */ }
};

优点:无虚拟表开销(编译期分派),适合性能敏感场景;缺点:不能在运行时基类指针上统一操作(不是运行时多态)。

常见坑与问答(FAQ)

  • Q:覆写没有被调用?
    A:可能没有把基类函数声明为 virtual,或发生了切片(by-value),或签名不同(const/ref/参数/返回类型不一致导致并非覆盖)。用 override 可快速发现签名问题。
  • Q:为什么 delete base_ptr 导致内存泄漏/未定义行为?
    A:基类析构函数不是 virtual,导致派生析构未被调用。多态基类应有虚析构。
  • Q:什么时候用虚继承?
    A:只有在确实需要解决菱形重复基类子对象时使用;它有运行时成本,通常尽量避免复杂的多重继承设计。
  • Q:继承还是组合?
    A:如果是“is-a” 用继承;如果只是“has-a” 或复用实现首选组合(成员持有)—组合更灵活、耦合更低。
  • Q:如何安全下转(downcast)?
    A:使用 dynamic_cast(带多态基类)或设计更清晰的 API(避免运行时检查),谨慎使用 static_cast

实战示例(菱形 + 构造参数)

#include <iostream>

struct A {
    A(int v) : x(v) { std::cout<<"A("<<x<<")\n"; }
    int x;
};

struct B : virtual A {
    B(int v) : A(v) {} // 无效?B 不能单独决定虚基的初始化(取决于 most-derived)
};

struct C : virtual A {
    C(int v) : A(v) {}
};

struct D : B, C {
    D(int v) : A(v), B(v), C(v) { // most-derived D 必须显式初始化虚基 A
        std::cout<<"D\n";
    }
};

int main(){
    D d(42);
    std::cout<<d.x<<"\n";
}

输出会显示 A(42) 只被构造一次(由 D 初始化),然后 D 的构造继续。


网站公告

今日签到

点亮在社区的每一天
去签到