首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >面试官:C++ 支持继承,C 语言不支持继承,分别如何实现多态?

面试官:C++ 支持继承,C 语言不支持继承,分别如何实现多态?

作者头像
早起的鸟儿有虫吃
发布2025-11-20 15:45:20
发布2025-11-20 15:45:20
130
举报

别想太多只管去面

21 天冲击大厂第一周 c++高频面试解析第二天开始来了

众所周知,

C 语言不支持

函数重载:需要基于参数类型的重载决议+符号改名;C 的链接模型不支持,C++ 标准规定了

模板(静态多态):需要编译期实例化机制;这是 C++ 的核心编译期元编程能力,C 语言规范无此机制

虚函数(动态多态):需要对象模型(vptr/vtable)、RTTI、调用约定支持;C 没有对象模型,C++ 规定了并由编译器生成

C++ 原生支持。

按理说,大二学完就能胜任 C 项目,甚至用 C 语言应对考研也没问题。

结果 学校一开 C++ 课,感觉 c++更高级的 误区中(很多工能不是 c++实现的,依赖编译器等其他库实现)

c++位置
c++位置

c++位置

现实是在电信等传统企业,

前端设计使用 java,后端设计 c++,不断维护老旧项目,

根本用不上特性,基本 if else 判断,跟谈不上架构了,

一入宫门深似海,感觉是 历史的倒退,

然后 跳槽去互联网大厂工作 0-5 年同学 工作面试,十分重视 c++基础知识,

别想太多只管去面: 第二天 函数篇开始 欢迎留言讨论

一、序言:为什么 C 不支持重载,而 C++ 支持

图1-程序员的自我修养_——链接装载与库
图1-程序员的自我修养_——链接装载与库

图1-程序员的自我修养_——链接装载与库

根据 图 1- 你想到

c 语言是根据函数名称产生编译符号

c++ 通过函数名称,参数,返回值 通过

因此 c++支持函数重载

堆栈中发现了一个特别长的函数 _ZNKSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE7compareEPKc,使用 c++filt 命令来还原函数:

代码语言:javascript
复制
$ c++filt _ZNKSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE7compareEPKc
std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::compare(char const*) const

符号可以通过 nm 命令获取

在升级一下

1. 1 提供更稳定用程序二进制接口 ( ABI )

本章所指的“二进制兼容性”是在升级(也可能是bugfix)库文件的时候,不 必重新编译使用了这个库的可执行文件或其他库文件,并且程序的功能不被破坏

C ABI 使用平坦符号名(无 name mangling)易于被 Python/Rust/Go 等通过 直接调用。

万一要跨语言怎么办?很简单,暴露 C 语言的接口。Java 有 JNI 可以调用 C 语言的代码;Python/Perl/Ruby 等等的解释器都是 C 语言编写的,使用 C 函数也不在话下

很多 C++/Rust 库对外暴露 C ABI 接口以获得“二进制兼容”和“长期稳定”的好处

什是ABI,注意不是 API,

基础功能库的方式提供例如 glibc,使用者需要调用

这里不考虑云,微服务,只考虑单机 Linux 系统你发现 so 二进制库

更多阅读:陈硕 C++ 工程实践(4):二进制兼容性

对比
对比

对比

操作系统的system call可以看成Kernel与User space的interface,kernel在这 个意义下也可以当成sharedlibrary,

你可以把内核从2.6.30升级到2.6.35,而不需要 重新编译所有用户态的程序。

内核选择 C ABI,更稳定。

若要长期稳定的二进制兼容(尤其内核/驱动/系统调用层面),优先 C ABI。

为什么 C ABI 更稳

符号简单(无 name mangling),跨编译器/版本更可控

调用约定/数据布局明确,平台文档化,几十年基本不变

无异常/RTTI/模板实例化等跨边界不确定因素

Linux 等操作系统对“系统调用 ABI”基本冻结,长期兼容

为什么 C++ ABI 不稳

多套 ABI:Itanium(Unix-like)与 MSVC(Windows)差异大

标准库/容器/std::string/异常模型随实现与版本变化

类布局/vtable/内联/模板实例化对编译器与选项敏感

小版本升级也可能破坏二进制兼容

1.2 如何理解:请看 C++ 工程实践:避免使用虚函数作为库的接口

以虚函数作为接口在二进制兼容性方面有本质困难:“一旦发布,不能修改”

C++ 工程实践(4):二进制兼容性

![ Linux 多线程服务端编程:使用 muduo C++ 网络库](https://www.chenshuo.com/book/cover.jpg 解决 60% 基础面试)

https://www.chenshuo.com/book/

代码语言:javascript
复制
第 10 章 C++ 编译链接模型精要10.1 C 语言的编译模型及其成因10.1.1 为什么 C 语言需要预处理10.1.2 C 语言的编译模型10.2 C++ 的编译模型10.2.1 单遍编译10.2.2 前向声明10.3 C++ 链接(linking)10.3.1 函数重载10.3.2 inline 函数10.3.3 模板10.3.4 虚函数10.4 工程项目中头文件的使用规则10.4.1 头文件的害处10.4.2 头文件的使用规则10.5 工程项目中库文件的组织原则10.5.1 动态库是有害的10.5.2 静态库也好不到哪儿去10.5.3 源码编译是王道第 11 章 反思 C++ 面向对象与虚函数11.1 朴实的 C++ 设计11.2 程序库的二进制兼容性11.2.1 什么是二进制兼容性11.2.2 有哪些情况会破坏库的 ABI11.2.3 哪些做法多半是安全的11.2.4 反面教材:COM11.2.5 解决办法11.3 避免使用虚函数作为库的接口11.3.1 C++ 程序库的作者的生存环境11.3.2 虚函数作为库的接口的两大用途11.3.3 虚函数作为接口的弊端11.3.4 假如 Linux 系统调用以 COM 接口方式实现11.3.5 Java 是如何应对的11.4 动态库接口的推荐做法11.5 以 boost::function 和 boost::bind 取代虚函数

如果你打算新写一个 C++ library,那么通常要做以下几个决策:

以什么方式发布?动态库还是静态库?(本文不考虑源代码发布这种情况,这其实和静态库类似。)

以什么方式暴露库的接口?可选的做法有:以全局(含 namespace 级别)函数为接口、以 class 的 non-virtual 成员函数为接口、以 virtual 函数为接口(interface)。

Java 程序员没有这么多需要考虑的,直接写 class 成员函数就行,最多考虑一下要不要给 method 或 class 标上 final。也不必考虑动态库静态库,都是 .jar 文件。

接口类型

客户端维护成本

原因简述

Virtual 函数(interface)

新功能需新 interface,多版本管理复杂

Non-virtual + Pimpl

可原地扩展,客户端无需改动已有代码

Virtual 函数作为库接口(interface)

假设库用 virtual 函数提供功能:

代码语言:javascript
复制
class Graphics {
public:
    virtual void drawLine(int x0,int y0,int x1,int y1);
    virtual void drawRectangle(int x0,int y0,int x1,int y1);
};

新增功能

如果要新增 drawLine(double x0,double y0,double x1,double y1)

不能直接加到原来的 Graphics 类(会破坏 vtable,导致客户端二进制不可用)

动态库里的 virtual 函数是通过 vtable offset 调用的。

一旦你在原来的类里新增 virtual 函数,vtable 排列就变了,客户端旧的二进制程序仍然调用旧 vtable offset,会调用错误函数

必须创建新 interface:

代码语言:javascript
复制
class Graphics2 : public Graphics {
    virtual void drawLine(double x0,double y0,double x1,double y1);
};

客户端影响

Non-virtual 成员函数 + Pimpl

假设库用普通成员函数,且内部用 Pimpl 封装实现:

代码语言:javascript
复制
class Graphics {
    class Impl;
    std::unique_ptr<Impl> impl;
public:
    void drawLine(int x0,int y0,int x1,int y1);
    void drawRectangle(int x0,int y0,int x1,int y1);
};

新增功能

可以在 Graphics 类原地增加新函数:

代码语言:javascript
复制
void drawLine(double x0,double y0,double x1,double y1);

内部实现放在 Impl 中,客户端不需要修改现有对象

客户端影响

旧功能继续可用,不受影响

二、函数基础知识回顾

C语言不支持函数重载,但是提供 2 个方式解决多态问题

库 方式 在链接时候 提供强弱符号 参考 c++ new 实现

定义全局数组,里面函数指针方式 存储

回到 c++,c++支持函数重载,带来便利同时 带来复杂性 在继承情况 函数被隐藏情况

C++中

函数重载(Overloading)

重写(Overriding)

覆盖(Hiding)

C++11的 override关键字能帮助编译器检查是否成功覆盖

2.1 什么是函数重载,隐藏,覆盖

特性

函数重载 (Overloading)

函数覆盖/重写 (Overriding)

函数隐藏 (Hiding)

作用域

同一个类或作用域内

分别位于基类和派生类中

分别位于基类和派生类中

函数名

相同

相同

相同

参数列表

必须不同

必须相同

可以相同,也可以不同

返回类型

可以不同

在C++11中,派生类函数返回类型必须相同或为协变类型

可以不同

virtual关键字

可有可无

基类函数必须有

与虚函数无关,两种情况都会触发隐藏

函数隐藏是由函数名相同触发的名称屏蔽机制

而虚函数和覆盖是为了实现动态多态

虚函数本身不解决隐藏问题

代码语言:javascript
复制
#include <iostream>

using namespace std;

class Base {

public:

	void func(int x) { } // 非虚函数,版本1

	virtual void func(double d) { } // 虚函数,版本2

};

class Derived : public Base
{

public:

	// 情况1:参数不同(const char*),隐藏了Base中所有的func函数

	void func(const char* s) { }

};

void test1()

{

	Derived d;

	d.func("Hello"); // 正确:调用Derived::func(const char*)

	// d.func(10); // 错误!Base::func(int) 已被隐藏

	// d.func(3.14); // 错误!Base::func(double) 也被隐藏了



	// 必须使用作用域解析运算符来显式调用基类被隐藏的函数

	d.Base::func(10); // 正确:调用Base::func(int)

	d.Base::func(3.14); // 正确:调用Base::func(double)

}

2.2 为什么每个类有且仅有一张虚表(vtable),不是虚指针

多重继承 对象构造顺序

c++对象模型
c++对象模型

c++对象模型

每个章节看 解决 90% c++ 面试
每个章节看 解决 90% c++ 面试

每个章节看 解决 90% c++ 面试

line a =b --拷贝构造函数

a =b 调用 ---复制operater 操作

2.3 虚表(vtable)和 vptr 初始化顺序 有多少个

类结构

对象内含 vptr 数量

对应 vtable 数量

vptr 来源

vtable 所属

单继承(Base→Derived)

1

1

继承自 Base

Derived 重新生成

多重继承(Base1, Base2→Derived)

2

2

分别继承自 Base1、Base2

Derived 重新生成两张表

虚继承(virtual Base)

通常 1(共享)

1

来自虚基类

由最派生类统一生成

单继承示意图

代码语言:javascript
复制
Derived d;  // Derived : Base

内存布局:
+-----------------+
| Base 子对象     |
|  +-------------+|
|  | vptr ------> Derived::vtable |
|  +-------------+|
|  Base 成员数据  |
+-----------------+
| Derived 成员数据 |
+-----------------+

说明:
- Base 子对象里有一个 vptr,指向 Derived 的 vtable(因为 Derived 覆盖了 Base 的虚函数)。
- Derived 本身没有额外 vptr。

vtable(Derived):

代码语言:javascript
复制
Derived::vtable:+---------+| f() -> Derived::f |+---------+

多重继承示意图

代码语言:javascript
复制

struct Base1 { virtual void f(); };
struct Base2 { virtual void g(); };
struct Derived : Base1, Base2 {
    void f() override;
    void g() override;
};


Derived d;  // Derived : Base1, Base2

内存布局:
+-----------------+
| Base1 子对象    |
|  +-------------+|
|  | vptr ------> Derived::Base1_vtable |
|  +-------------+|
|  Base1 成员数据 |
+-----------------+
| Base2 子对象    |
|  +-------------+|
|  | vptr ------> Derived::Base2_vtable |
|  +-------------+|
|  Base2 成员数据 |
+-----------------+
| Derived 成员数据 |
+-----------------+

说明:
- 每个基类子对象都有独立 vptr。
- 每个 vptr 指向对应派生类生成的新 vtable。
- Derived 自身没有额外 vptr(除非声明新的虚函数)。

Derived vtable(覆盖虚函数):

代码语言:javascript
复制
Derived::Base1_vtable:
+-----------------+
| f() -> Derived::f |
+-----------------+

Derived::Base2_vtable:
+-----------------+
| g() -> Derived::g |
+-----------------+

菱形继承

代码语言:javascript
复制
     Base     /  \    A    B     \  /   DerivedDerived├─ A::Base.x├─ B::Base.x└─ Derived自己的成员struct A : virtual Base {};struct B : virtual Base {};struct Derived : public A, public B {}Derived├─ A (虚基指针 vbptr -> 指向共享的Base)├─ B (虚基指针 vbptr -> 指向共享的Base)├─ 共享的 Base.x(只有一份)└─ Derived自己的成员

Derived 对象里有 两份 Base.x,访问 x 会二义性 ✅ 要点

1

每个基类子对象一张虚表

2

派生类覆盖虚函数 → 生成新 vtable,vptr 指向新表

3

派生类自身不产生额外 vptr(除非自己声明虚函数)

4

客户端通过基类指针调用虚函数时,vptr 定位到对应派生类函数

虚继承是编译器层面的机制, 通过引入 虚基表(vbtable)虚基指针(vbptr) 来确保基类唯一。

2.4 虚析构函数

构造函数是虚函数吗?

不是。C++ 禁止构造函数为虚函数(对象尚未构造完成包括**虚基指针(vbptr,无法进行动态分派)

构造函数能调用虚函数吗?

能写,但不会动态分派。构造期间的虚函数调用会绑定到“当前正在构造的类”的版本,派生类版本不会被调用。

析构函数是虚函数吗?

可以是,且“用于多态基类时应当为虚”。否则通过基类指针删除派生对象会导致未定义行为(派生析构不被调用)。

析构函数能调用虚函数吗?

能写,但同样不会动态分派。

析构期间调用虚函数会绑定到“当前正在析构的类”的版本;派生部分已被销毁,派生重写不会被调用

三 用 std::function / lambda 替代虚函数

要点

核心思想:用组合 + 回调(可调用对象)替代继承 + 虚函数。

工具演进:优先用 C++11 的 std::function + lambda。

思想

关键词

优点

旧:虚函数体系

继承 + 重写

运行时多态,但强耦合、脆弱

新:可调用对象

组合 + 回调

更灵活、更稳定、更符合现代 C++


背景:为什么要用 std::function / lambda 替代虚函数

传统面向对象的做法:

代码语言:javascript
复制
struct Animal {
    virtual void speak() = 0;
    virtual ~Animal() = default;
};

struct Dog : Animal {
    void speak() override { std::cout << "Woof\n"; }
};

调用:

代码语言:javascript
复制
std::unique_ptr<Animal> a = std::make_unique<Dog>();
a->speak();

✅ 优点:运行时多态 ❌ 缺点:

需要继承体系

引入 vtable、vptr,破坏 ABI 稳定

增加编译耦合(修改基类需重新编译所有派生类)

不易组合(类层次一旦固定很难变换行为)

💡 新思路:组合 + 可调用对象

取而代之,可以用 组合(has-a) + 函数回调(callable)

代码语言:javascript
复制
#include <functional>
#include <iostream>

struct Animal {
    std::function<void()> speak;
};

使用时:

代码语言:javascript
复制
Animal dog{ [] { std::cout << "Woof\n"; } };
Animal cat{ [] { std::cout << "Meow\n"; } };

dog.speak();
cat.speak();

👉 没有继承,也没有虚函数表。

行为(speak)被 注入为可调用对象,即“策略”(Strategy Pattern)。

🔍 原理理解

特性

虚函数实现

std::function 实现

多态类型

编译期定义,运行期通过 vtable 调度

运行期通过函数对象类型擦除调度

内存结构

每个对象含 vptr

对象中含 std::function 封装(内部存指针 + 调用器)

扩展方式

通过继承和 override

通过组合和注入新 callable

二进制兼容性

改一个虚函数签名会破坏 ABI

ABI 稳定,不影响接口结构

性能

一次 vtable 间接调用

一次函数指针间接调用(几乎一样)

应用场景举例

策略模式(Strategy Pattern)

代码语言:javascript
复制
struct Sorter {
    std::function<void(std::vector<int>&)> sort;
};

Sorter quicksorter{ [](auto& v){ std::sort(v.begin(), v.end()); } };
Sorter reverseSorter{ [](auto& v){ std::sort(v.rbegin(), v.rend()); } };

std::vector<int> v{3,1,2};
quicksorter.sort(v);

回调事件系统

代码语言:javascript
复制
struct Button {
    std::function<void()> onClick;
};

Button b;
b.onClick = [] { std::cout << "Clicked!\n"; };
b.onClick();

以前这类场景往往用 virtual void onClick()

库设计上更易维护

发布动态库时,只暴露:

代码语言:javascript
复制
class Engine {
public:
    void setOnEvent(std::function<void(int)> cb);
};

客户端可以自由绑定 lambda,无需继承、无需重新编译。

四、c语言没有继承,如何 实现多态

💡一句话总结:

C 语言通过“函数指针 + 结构体组合”就能实现多态。

这正是 C++ 虚函数表(vtable)的本质来源。

C++ 概念

C 实现方式

说明

virtual 函数

函数指针

存在于“函数表”中

vtable

结构体函数表(ShapeVTable)

每个类型一个表

vptr

指向 vtable 的指针

每个对象一个 vptr

override

不同子类提供不同函数表

替换函数指针

Shape* s = new Circle

向上转型为基类指针

通过 vptr 调度行为

我们来结合 Linux 内核中的文件系统 VFS 层(Virtual File System)

—— 内核是如何用 C语言 + 函数指针 实现“面向对象 + 多态”的。


4.1 一、背景:VFS 是个“多态接口层”

Linux 支持 ext4、xfs、btrfs、procfs、tmpfs …… 但所有系统调用(open, read, write 等) 都通过统一的接口访问。

也就是说:

用户调用 read(fd, buf, len) → 内核根据 fd 找到文件对象 → 文件对象内部指向不同文件系统实现的函数表 → 调用 file->f_op->read(...) 同样的调用,不同的实现。 这就是“多态”。


4.2 二、核心数据结构对照表

C++ 概念

内核对应结构

说明

class Shape

struct file_operations

定义接口(read/write/...)

virtual 函数

函数指针成员

每个文件系统定义自己的函数表

vtable

file_operations 结构体实例

每个文件系统有一份表

vptr

struct file → f_op

每个打开的文件对象持有指针

override

不同文件系统实现不同函数

替换函数表内容

动态派发

file->f_op->read(file, buf, len, pos)

根据 f_op 指针动态选择实现

4.3、内核示例:struct file_operations

(来源:include/linux/fs.h

代码语言:javascript
复制
struct file_operations {
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    // ... 其他很多操作
};

这就像是一个“虚函数表”(vtable)。

4.4、每个文件系统提供自己的实现表

例如 ext4 的定义(摘自 fs/ext4/file.c):

代码语言:javascript
复制
const struct file_operations ext4_file_operations = {
    .read_iter  = ext4_file_read_iter,
    .write_iter = ext4_file_write_iter,
    .open       = ext4_file_open,
    .release    = ext4_release_file,
};

tmpfs 的定义(fs/tmpfs/file.c):

代码语言:javascript
复制
const struct file_operations shmem_file_operations = {
    .read_iter  = shmem_file_read_iter,
    .write_iter = shmem_file_write_iter,
    .open       = shmem_file_open,
};

这就像 C++ 中不同派生类实现了自己的虚函数。


4.5、运行时绑定(C 的多态调用)

当用户打开一个文件时,内核会创建 struct file 对象:

代码语言:javascript
复制
struct file {
    const struct file_operations *f_op;  // vptr!!
    void *private_data;
    ...
};

比如:

打开 ext4 文件 → f_op = &ext4_file_operations

打开 tmpfs 文件 → f_op = &shmem_file_operations


调用过程示意

步骤

动作

C 语言逻辑

C++ 类比

1

用户调用 read(fd)

内核找到 file

找到对象

2

内核执行 file->f_op->read_iter(...)

根据函数表调用

调用虚函数

3

具体执行 ext4_file_read_iter() 或 shmem_file_read_iter()

动态分发

运行时多态

👉 即使是纯 C 语言,read() 的行为也根据文件类型动态变化。


代码语言:javascript
复制
struct file
   │
   ├──> f_op ──► ext4_file_operations
   │                   ├── read_iter → ext4_file_read_iter()
   │                   ├── write_iter → ext4_file_write_iter()
   │
   └──> f_op ──► shmem_file_operations
                       ├── read_iter → shmem_file_read_iter()
                       ├── write_iter → shmem_file_write_iter()

总结对照表

面向对象术语

内核中体现

说明

类定义

struct file_operations

接口定义

子类

各文件系统定义自己的 ops 表

不同实现

对象实例

struct file

拥有指针指向 vtable

虚函数

函数指针

通过函数表调用

继承/多态

函数表组合

运行时动态绑定

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-10-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 后端开发成长指南 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、序言:为什么 C 不支持重载,而 C++ 支持
    • 1. 1 提供更稳定用程序二进制接口 ( ABI )
    • 1.2 如何理解:请看 C++ 工程实践:避免使用虚函数作为库的接口
  • 二、函数基础知识回顾
    • 2.1 什么是函数重载,隐藏,覆盖
    • 2.2 为什么每个类有且仅有一张虚表(vtable),不是虚指针
      • 多重继承 对象构造顺序
    • 2.3 虚表(vtable)和 vptr 初始化顺序 有多少个
      • 单继承示意图
      • 多重继承示意图
      • 菱形继承
      • 2.4 虚析构函数
  • 三 用 std::function / lambda 替代虚函数
    • 💡 新思路:组合 + 可调用对象
    • 🔍 原理理解
      • 策略模式(Strategy Pattern)
      • 回调事件系统
      • 库设计上更易维护
  • 四、c语言没有继承,如何 实现多态
    • 4.1 一、背景:VFS 是个“多态接口层”
    • 4.2 二、核心数据结构对照表
    • 4.3、内核示例:struct file_operations
    • 4.4、每个文件系统提供自己的实现表
      • 例如 ext4 的定义(摘自 fs/ext4/file.c):
      • tmpfs 的定义(fs/tmpfs/file.c):
    • 4.5、运行时绑定(C 的多态调用)
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档