c++中多重继承与虚继承的实现

前言

多重继承(multiple inheritance, MI)是 C++ 从 C++ 诞生之初就带有的特性,也是臭名昭著、争议最大的特性之一。Java、C# 都选择了"单继承 + 接口",唯独 C++ 保留了完全的多重继承。

为什么?因为它能表达一类真实存在的关系:一个对象同时是两件事 。比如 iostream 同时是 istream 和 ostream;一个 AssistantProfessor 同时是 Teacher 和 Researcher。

但多重继承带来一个尖锐的问题:如果 Teacher 和 Researcher 都继承自 Person,那么一个 AssistantProfessor 里有两份 Person 子对象 。调用 p.name() 会歧义,AssistantProfessor 的指针转成 Person* 会得到两个不同地址。

虚继承(virtual inheritance)就是为解决这个问题而生的。本文从内存布局 和指针调整两个底层视角,把这两件事讲透。

一、多重继承的基本形态

cpp 复制代码
#include <iostream>



struct Teacher {

    void teach() const { std::cout << "teaching\n"; }

    int teacherData = 1;

};



struct Researcher {

    void research() const { std::cout << "researching\n"; }

    int researcherData = 2;

};



struct AssistantProfessor : Teacher, Researcher {   // 多继承

    void work() const { teach(); research(); }

};



int main() {

    AssistantProfessor ap;

    ap.teach();       // 来自 Teacher

    ap.research();    // 来自 Researcher

    ap.work();

}

多继承本身不复杂:对象的内存是各基类子对象按声明顺序依次排列,再放自己的成员。

text 复制代码
AssistantProfessor 内存布局(示意)

+---------------------------+

| Teacher 子对象            |

|   teacherData (int)       |  offset 0

+---------------------------+

| Researcher 子对象         |

|   researcherData (int)    |  offset 4

+---------------------------+

| AssistantProfessor 成员    |

+---------------------------+

由于两个基类子对象在对象内的偏移不同,AssistantProfessor* 转成 Teacher* 是 offset 0(无需调整),转成 Researcher* 则需要 偏移 4 字节。

指针调整:编译器帮你做的事

cpp 复制代码
AssistantProfessor ap;

Teacher* t = &ap;      // 地址不变

Researcher* r = &ap;   // 编译器自动 +4(或者说指向对象内偏移 4 的位置)



std::cout << (void*)&ap << "\n";   // 例:0x7ffd...a0

std::cout << (void*)t  << "\n";    // 同上

std::cout << (void*)r  << "\n";    // 0x7ffd...a4,比上面大 4

这一段很关键:多重继承下的向上转型(upcast)不是简单的"地址不变",而是可能带偏移的指针运算。这个偏移在编译期就知道,所以静态转型是零开销的(就加个常量)。

二、菱形继承(diamond inheritance)问题

现在给两个基类找一个共同祖先:

cpp 复制代码
struct Person {

    std::string name = "unknown";

    int age = 0;

};



struct Teacher : Person { int teacherId = 100; };

struct Researcher : Person { int researcherId = 200; };



struct AssistantProfessor : Teacher, Researcher {};

此时 AssistantProfessor 的布局变成:

text 复制代码
+----------------------------+

| Teacher 子对象              |

|   Person 子对象 #1          |   name, age

|   teacherId                 |

+----------------------------+

| Researcher 子对象           |

|   Person 子对象 #2          |   name, age  ← 又一份!

|   researcherId              |

+----------------------------+

这就是菱形继承(diamond inheritance,也叫"死亡钻石")。它带来三个具体问题:

问题 1:名字二义性

cpp 复制代码
AssistantProfessor ap;

// ap.name = "Bob";          // ❌ error: request for member 'name' is ambiguous

ap.Teacher::name = "Bob";    // 必须显式限定

ap.Researcher::name = "Bob"; // 改的是另一份!

问题 2:数据重复,逻辑上自相矛盾

一个"人"居然有两个名字、两个年龄。改了一个,另一个还是旧的。从领域模型看,这是彻底的错误------一个助理教授只是一个人。

问题 3:无法直接转成 Person*

cpp 复制代码
Person* p = &ap;    // ❌ error: 'Person' is an ambiguous base of 'AssistantProfessor'

Person* p1 = static_cast<Teacher*>(&ap);      // ✅ 需要指明路径

Person* p2 = static_cast<Researcher*>(&ap);   // ✅ 另一份 Person

std::cout << (p1 == p2);   // false!地址不同

而多态场景下这个问题更隐蔽:

cpp 复制代码
void greet(Person& p) { std::cout << p.name << "\n"; }



AssistantProfessor ap;

// greet(ap);       // ❌ 编译错误:二义

greet(static_cast<Teacher&>(ap));   // ✅ 但只看到 Teacher 那份数据

三、虚继承:共享唯一的一份基类

虚继承(virtual inheritance)的解法是:让最底层的派生类直接构造虚基类,所有中间层共享同一份虚基类子对象。

cpp 复制代码
struct Person {

    std::string name = "unknown";

};



struct Teacher : virtual Person { int teacherId = 100; };

struct Researcher : virtual Person { int researcherId = 200; };



struct AssistantProfessor : Teacher, Researcher {};   // 现在只有一份 Person

此时:

cpp 复制代码
AssistantProfessor ap;

ap.name = "Bob";                   // ✅ 不再二义

Person* p = &ap;                   // ✅ 唯一路径

std::cout << sizeof(AssistantProfessor) << "\n";

3.1 内存布局:虚基类被挪到对象末尾

虚继承的典型实现(Itanium C++ ABI,GCC/Clang 使用)是:虚基类子对象被放到对象的最末尾,中间层通过"虚基类偏移表"(virtual base table,简称 vbtable)在运行时找到它。

text 复制代码
AssistantProfessor 虚继承布局(示意)

+-----------------------------+

| Teacher 子对象               |

|   vbtable 指针  ← 指向虚基表  |

|   teacherId                  |

+-----------------------------+

| Researcher 子对象            |

|   vbtable 指针               |

|   researcherId               |

+-----------------------------+

| Person 子对象(虚基类)       |  ← 只有一份,位于末尾

|   name                       |

+-----------------------------+

Teacher 子对象里的 vbtable 指针告诉你:"要找到虚基类 Person,从我的地址往后加 offset"。

3.2 代价:每一次访问虚基类成员都要查表

cpp 复制代码
void Teacher::setName(const std::string& n) {

    this->name = n;   // 编译成:从 vbtable 取出偏移 → 加上基址 → 写入

}

对比非虚继承(偏移是编译期常量),虚继承下访问虚基类成员需要一次额外的内存读取 + 一次加法。这就是虚继承的性能代价。

对比项 普通继承 虚继承

|---------|-------|--------|
| 基类子对象份数 | 每路径一份 | 全局唯一一份 |

|------|-------|--------------|
| 基类偏移 | 编译期常量 | 运行时查 vbtable |

|--------|---------|---------|
| 成员访问开销 | 1 次内存访问 | 2 次内存访问 |

|------|----|---------------|
| 对象大小 | 较小 | 增加 vbtable 指针 |

|---------------|----|-------------------|
| 能否安全 downcast | 可以 | 需要 dynamic_cast |

四、虚继承最重要的规则:最底层派生类负责构造虚基类

这是虚继承最反直觉的一点,也是面试高频考点。

规则:虚基类的构造函数由"最底层的派生类"(most-derived class)调用,中间层对虚基类构造函数的调用会被忽略。

cpp 复制代码
#include <iostream>



struct Person {

    std::string name;

    explicit Person(std::string n) : name(std::move(n)) {

        std::cout << "Person(" << name << ")\n";

    }

};



struct Teacher : virtual Person {

    explicit Teacher(std::string n) : Person(n + "/T") {

        std::cout << "Teacher\n";

    }

};



struct Researcher : virtual Person {

    explicit Researcher(std::string n) : Person(n + "/R") {

        std::cout << "Researcher\n";

    }

};



struct AssistantProfessor : Teacher, Researcher {

    // 必须在这里显式构造虚基类 Person,否则会调用 Person 的默认构造

    AssistantProfessor(std::string n)

        : Person(n), Teacher(n), Researcher(n) {

        std::cout << "AssistantProfessor\n";

    }

};



int main() {

    AssistantProfessor ap("Bob");

    std::cout << "name = " << ap.name << "\n";

}

输出:

text 复制代码
Person(Bob)          ← 只有这一行!Teacher 和 Researcher 里的 Person(n+"/T") 被忽略

Teacher

Researcher

AssistantProfessor

name = Bob

注意 name 最终是 "Bob",而不是 "Bob/T" 或 "Bob/R"。

构造顺序也有讲究:

  1. 虚基类子对象(按深度优先、从左到右 的顺序)------这里是 Person;
  2. 非虚基类(按声明顺序)------Teacher、Researcher;
  3. 自己的成员。

析构顺序严格相反。

**如果 Person 没有默认构造函数,而 AssistantProfessor 又忘了在初始化列表里构造它,就会编译失败。**这是虚继承最常见的编译错误来源。

五、完整可编译示例:带虚析构的菱形继承

cpp 复制代码
#include <iostream>

#include <memory>

#include <string>

#include <vector>



struct Person {

    std::string name;

    explicit Person(std::string n = "anon") : name(std::move(n)) {

        std::cout << "  ctor Person(" << name << ")\n";

    }

    virtual ~Person() { std::cout << "  dtor Person(" << name << ")\n"; }

    virtual void who() const { std::cout << "I am " << name << "\n"; }

};



struct Teacher : virtual Person {

    int courseCount = 3;

    explicit Teacher(std::string n = "anon") : Person(std::move(n)) {

        std::cout << "  ctor Teacher\n";

    }

    ~Teacher() override { std::cout << "  dtor Teacher\n"; }

    void teach() const { std::cout << name << " teaches " << courseCount << " courses\n"; }

};



struct Researcher : virtual Person {

    int paperCount = 12;

    explicit Researcher(std::string n = "anon") : Person(std::move(n)) {

        std::cout << "  ctor Researcher\n";

    }

    ~Researcher() override { std::cout << "  dtor Researcher\n"; }

    void research() const { std::cout << name << " wrote " << paperCount << " papers\n"; }

};



struct AssistantProfessor : Teacher, Researcher {

    AssistantProfessor(std::string n, int courses, int papers)

        : Person(std::move(n)), Teacher("x"), Researcher("y") {

        courseCount = courses;

        paperCount = papers;

        std::cout << "  ctor AssistantProfessor\n";

    }

    ~AssistantProfessor() override { std::cout << "  dtor AssistantProfessor\n"; }

    void who() const override { std::cout << "I am AsstProf " << name << "\n"; }

};



int main() {

    {

        std::cout << "=== 构造/析构顺序 ===\n";

        AssistantProfessor ap("Bob", 2, 20);

        ap.who();

        ap.teach();

        ap.research();

        std::cout << "sizeof = " << sizeof(AssistantProfessor) << "\n";



        std::cout << "=== 多态 ===\n";

        Person& p = ap;         // 虚继承下唯一路径,无需 cast

        p.who();

    }

    std::cout << "=== 结束 ===\n";

}

编译运行:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -o diamond diamond.cpp && ./diamond

关键观察:

  • Person 只构造一次;
  • Teacher("x") / Researcher("y") 中的 "x"、"y" 被完全忽略(因为它们是虚基类构造函数实参);
  • Person& p = ap; 在虚继承下可以直接完成,不需要 dynamic_cast。

常见坑点

坑点 1:菱形继承 + 非虚 = 数据不一致

❌ 错误写法:

cpp 复制代码
struct Person { std::string name; };

struct Teacher : Person {};

struct Researcher : Person {};

struct AP : Teacher, Researcher {};



AP ap;

ap.Teacher::name = "Bob";

ap.Researcher::name = "Alice";

// 现在 ap 有"两个名字",谁都说不清它到底是谁

✅ 正确写法(在共享基类上加 virtual):

cpp 复制代码
struct Teacher : virtual Person {};

struct Researcher : virtual Person {};

struct AP : Teacher, Researcher {};



AP ap;

ap.name = "Bob";   // 只有一份

判断标准:如果一个基类可能出现"菱形",且它代表某种唯一身份,就应该用虚继承(或者干脆改用组合)。

坑点 2:忘了在最底层构造虚基类

❌ 错误写法:

cpp 复制代码
struct Person {

    explicit Person(std::string n);   // 没有默认构造

};

struct Teacher : virtual Person {

    Teacher() : Person("default") {}   // 这个不会被 AP 用到

};

struct AP : Teacher {

    AP() {}        // ❌ 编译错误:Person 无法默认构造

};

✅ 正确写法:

cpp 复制代码
struct AP : Teacher {

    AP() : Person("Bob"), Teacher() {}   // ✅ 最底层显式构造虚基类

};

记住:写在中间层的虚基类初始化实参会被静默忽略,编译器不会警告,但运行结果和你预期不同------这是最阴险的一类 bug。

坑点 3:多继承下 delete 基类指针的偏移问题

cpp 复制代码
struct A { virtual ~A() = default; int a; };

struct B { int b; };

struct C : A, B {};



int main() {

    C* c = new C;

    B* b = c;      // 注意:b 的地址比 c 大(偏移了 A 的大小)

    delete b;      // ❌ UB:B 没有虚析构,且 delete 不会回退偏移

}

✅ 正确写法:

cpp 复制代码
struct B { virtual ~B() = default; int b; };   // 加虚析构

// 或者:始终用创建时的类型(C*)来 delete

C* c = new C;

delete c;   // ✅

多继承下,通过二级基类指针 delete 时,如果该基类没有虚析构函数,析构函数不会做指针回退 ,释放的地址是错的。这是真实的内存破坏 bug。规则依然是:多态基类必须虚析构。

坑点 4:static_cast 在虚继承下不可靠

❌ 错误写法:

cpp 复制代码
struct V { int v; };

struct M : virtual V {};

struct D : M {};



void f(V* pv) {

    D* pd = static_cast<D*>(pv);   // ❌ 编译错误或未定义行为

}

从虚基类向下转型,编译器无法计算偏移(偏移在运行时才在 vbtable 里),static_cast 会直接拒绝编译。

✅ 正确写法:

cpp 复制代码
void f(V* pv) {

    D* pd = dynamic_cast<D*>(pv);   // ✅ 运行时查表,正确换算

    if (pd) { /* 使用 */ }

}

dynamic_cast 有运行时开销,但这是虚继承下唯一正确的下行转型方式。

总结

概念 含义 代价 建议

|------|-----------|--------|-----------|
| 多重继承 | 一个类继承多个基类 | 指针偏移调整 | 谨慎使用,优先组合 |

|------|-------------|------------|------------|
| 菱形继承 | 两条路径汇聚到同一基类 | 数据重复 + 二义性 | 通常意味着设计有问题 |

|-----|-----------|-------------------|-------------|
| 虚继承 | 共享唯一一份虚基类 | vbtable 查表 + 大小增加 | 只用于无数据成员的接口 |

|-------|----------------------|-----|------------|
| 最底层构造 | 虚基类由 most-derived 构造 | 易错点 | 初始化列表显式写出来 |

三句话收尾:

  1. 多继承的向上转型可能带偏移------所以别把对象地址想当然地当成基类地址。
  2. 虚继承解决的是"身份唯一性"问题,代价是运行时查表和复杂的构造规则。
  3. 如果感到纠结,用组合代替继承------面向对象的核心是"多态",不是"继承层级有多深"。
相关推荐
沫璃染墨1 小时前
《Qt从零入门系列(十一):Qt事件机制详解——从QEvent到鼠标、键盘与定时器事件》
c++·qt·ui·硬件工程·交互·个人开发·qt5
我是小白呀1 小时前
17-企业Workflow引擎怎么选:定时、队列、n8n、Temporal与Camunda
java·开发语言·人工智能·dubbo·workflow
维克兜率天1 小时前
【维克】均值回归:跌多了会涨,涨多了会跌
开发语言·笔记·python·算法·均值算法·回归·量化
实心儿儿2 小时前
Qt — Qt 窗口
开发语言·qt
2601_962218612 小时前
C++中分配器allocator的实现
开发语言·c++
wshzd2 小时前
LLM之Agent(104)|DeepSeek-Harness(十三)ReactLoopAgent 总览:kick → turn → step
开发语言·前端·javascript
共享家95272 小时前
C语言-408
c语言·开发语言
周杰偷奶茶2 小时前
【Java】运算符指南
java·开发语言
单片机设计·······2 小时前
【dz-1162】汽车前玻璃雨刮器设计
c++·单片机·嵌入式硬件