第 14 章 · 虚函数不实现 · 三种策略
配套代码:oop-in-c/code/14-pure-virtual/
类型机制完整了,上转下转都能干。但 ops 表里有一颗雷你可能还没意识到:如果某种 LED 在 init 的时候,ops->on 没填呢?
14.1 ops 表里有一颗雷 · NULL 函数指针崩溃
第 11 章演化出来的 led_on 长这样:
int led_on(struct led_base *me)
{
return me->ops->on(me);
}
如果某个子类 ops 表少填了 on:
static const struct led_ops broken_ops = {
.off = my_off,
/* on 忘了填 */
};
C 标准说静态存储的对象未显式初始化的字段会被零初始化,所以 broken_ops.on 是 NULL。
应用层调 led_on(handle),进到 me->ops->on(me),把 NULL 当函数地址跳过去。在 STM32 上一般是 0 地址(向量表起点),跳进去取出“函数指针“再跳,行为不可预测,多半是 HardFault 死循环。在 Linux 用户态收到 SIGSEGV,进程当场死。
编译器一句话不说就让你过了。它觉得你是故意填 NULL 的。

14.2 必填策略 · 调用前 assert
第一种做法:在父类的统一接口里加一行检查。led_base.c 实际写法:
/* led_base.c */
int led_on(struct led_base *me)
{
if (!me)
return -1;
assert(me->ops && me->ops->on &&
"led_on: subclass must implement on()");
return me->ops->on(me);
}
int led_off(struct led_base *me)
{
if (!me)
return -1;
assert(me->ops && me->ops->off &&
"led_off: subclass must implement off()");
return me->ops->off(me);
}
assert 在调试构建里直接 abort,告诉你哪个文件哪一行触发了。Release 构建把 NDEBUG 打开,assert 编译产物直接消失,0 开销。
调试期就抓住“忘记实现“的子类。这一类操作叫 必填,子类不实现,整个对象无效。
灯的 on 和 off 就是必填。一颗灯不能开、不能关,那还叫什么灯?合同里的必填项,不填合同无效。

14.3 选填策略 · 父类提供默认行为
但有些操作不是每种子类都需要自己实现。
set_brightness:PWM 灯支持调光,GPIO 灯不支持。GPIO 灯只有“开“和“关“,没有亮度概念。
如果让 led_set_brightness 也走 assert 必填策略,GPIO 子类就得写一个空函数:
static int gpio_set_brightness(struct led_base *me, uint8_t b)
{
(void)me;
(void)b;
return 0; /* 啥都不做 */
}
每个不支持调光的子类都得写一个空函数,烦。
更好的做法:把“安静跳过“的默认行为放到父类的统一接口里。led_base.c 实际写法:
/* led_base.c */
int led_set_brightness(struct led_base *me, uint8_t brightness)
{
if (!me || !me->ops)
return -1;
if (!me->ops->set_brightness) {
/* 默认行为:这种 LED 不支持调光,安静跳过 */
printf(" [%s] no dimming support, skip (brightness=%u)\n",
me->name, (unsigned)brightness);
return 0;
}
return me->ops->set_brightness(me, brightness);
}
子类填了 set_brightness 就走子类。子类没填,统一接口走默认。ops 表本身从来不改,NULL 就是 NULL,处理这个 NULL 的责任落在父类的统一接口上。
GPIO 子类的 ops 表只填了 on / off,set_brightness 字段被零初始化为 NULL:
/* led_gpio.c */
const struct led_ops led_ops_gpio = {
.on = gpio_on,
.off = gpio_off,
/* set_brightness 故意不填 -- GPIO 灯没有亮度概念 */
};
应用层调 led_set_brightness(&gpio_led.base, 50) 时走的就是父类默认那条分支,不崩。
这一类操作叫 选填。合同里的选填项,不填用默认条款,但合同主体没改过。

14.4 全必填策略 · 接口(interface)
把策略推到极致:如果一张 ops 表里每一个 op 都是必填呢?
LED 这个例子里 set_brightness 天然是选填(GPIO 灯没法调光),没法演示“全部必填“长什么样。换一个对象——传感器 sensor——它的 read / calibrate / self_test 三件套都是必填:一个 sensor 不能读,或者不能校准,或者不能自检,它就不算 sensor。
sensor_base.h 声明一份完全独立的父类,结构和 led_base 一模一样:一颗 ops 指针打头,加一个名字字段。两条线(led 和 sensor)的文件组织也一字对照——父类公开头(字段集 + ops 表 + 共有 init + 父类统一接口)集中在 sensor_base.h / .c,子类(temp_sensor)一对独立的 sensor_temp.h / .c。区别只在统一接口怎么处理 NULL。
/* sensor_base.h */
struct sensor_ops {
int (*read)(struct sensor_base *me, int32_t *out);
int (*calibrate)(struct sensor_base *me);
int (*self_test)(struct sensor_base *me);
};
/* sensor_base.c */
int sensor_read(struct sensor_base *me, int32_t *out)
{
if (!me || !out)
return -1;
assert(me->ops && me->ops->read &&
"sensor.read is part of the interface contract");
return me->ops->read(me, out);
}
int sensor_calibrate(struct sensor_base *me)
{
if (!me)
return -1;
assert(me->ops && me->ops->calibrate &&
"sensor.calibrate is part of the interface contract");
return me->ops->calibrate(me);
}
int sensor_self_test(struct sensor_base *me)
{
if (!me)
return -1;
assert(me->ops && me->ops->self_test &&
"sensor.self_test is part of the interface contract");
return me->ops->self_test(me);
}
三个 op 全部 assert。任何 sensor 子类要加进这套体系,三件套必须全填。少一个,调试期立刻爆。
这种“全是必填的 ops 表“,软件工程里叫 接口(interface)。一份只有规格、没有实现的合同。

真实工业项目里,传感器、电机、通讯模块这种“加入体系就要完整实现“的对象,ops 表都是接口风格。LED 这种“有些操作可有可无“的对象,是必填 + 选填混合。
14.5 三种策略一张表
把刚才三种放一起看:
| 策略 | C 里的写法 | C++ 里的写法 | 子类不填的后果 |
|---|---|---|---|
| 必填 | 统一接口里 assert | 纯虚函数 virtual void f()=0; | 调试期 assert 失败 / C++ 编译器拒绝实例化子类 |
| 选填 | 统一接口里检查 NULL,提供默认 | 虚函数有默认实现 virtual void f() {...} | 子类继承父类默认行为 |
| 全必填 | ops 表所有字段都走必填 | 全是纯虚函数的类(“接口”) | 子类必须实现每一个 |
C++ 里编译器替你把这三种区分开。C 里你自己在统一接口里实现这套区分。底下逻辑一样。

Linux 内核的 struct file_operations 就是混合策略的代表:read / write 这种关键操作必填,unlocked_ioctl / mmap 这些选填,文件系统不支持就 NULL,VFS 层走默认行为。第 18 章 § 18.1 会展开 file_operations 的实例。
14.6 C 对比 C++
class LedBase {
public:
virtual int on() = 0; /* 纯虚(必填) */
virtual int off() = 0; /* 纯虚 */
virtual int set_brightness(uint8_t b) { /* 虚(选填,有默认) */
(void)b;
return 0;
}
};
class LedGpio : public LedBase {
public:
int on() override { /* ... */ return 0; }
int off() override { /* ... */ return 0; }
/* set_brightness 不实现,继承父类默认 */
};
C++ 编译器看到子类没实现 on / off,编译期拒绝让你 LedGpio gpio_led;:抽象类不能直接实例化。
C 里编译器一句话不说,运行时 assert 才发现。区别只在出错的时机。
一句话:C 语言自己约束自己,C++ 编译器约束你。

14.7 视频里没讲透的几个细节
14.7.1 assert 在 release 构建里防线
assert 在 release 构建里被 NDEBUG 关掉。生产代码里如果你只靠 assert,关掉之后那一行就成了空语句,NULL 又能打过来了。
更稳的做法:assert + 错误返回值并存。
int led_on(struct led_base *me)
{
if (!me)
return -1;
assert(me->ops && me->ops->on);
if (!me->ops || !me->ops->on)
return -2; /* release 构建的最后一道闸 */
return me->ops->on(me);
}
调试期 assert 帮你定位问题,生产期错误码兜底。两个都不能少。
14.7.2 ops 表一定要 const
工业项目里 ops 表都是这样定义:
static const struct led_ops gpio_ops = {
.on = gpio_on,
.off = gpio_off,
};
static const 意味着这个表在编译期就确定,链接到 .rodata 段(只读)。运行时任何修改都会触发段错误。
为什么要 const?两个理由:
- 安全:跑飞的代码可能踩到 ops 表,把
on改成野指针。const + .rodata 让这种踩踏立刻爆出来,比静默崩好。 - 缓存:所有相同子类的对象共享一份 ops 表(一个 ops 实例服务全部 GPIO LED 对象),只读段对 cache 友好。
14.7.3 接口还是混合:什么时候选哪种
什么时候用全必填的接口风格,什么时候用必填 + 选填混合?
如果 ops 里的每一个 op,子类都必须自己实现才有意义,那就是接口。例子:sensor 模块的 read / calibrate / self_test,每一个传感器都得自己提供。
如果 ops 里有些 op 是“通用默认行为可用、子类需要的话覆写“,那就是混合。例子:LED 的 set_brightness,大部分 LED 默认走“不支持调光“是合理的。
判据:没有合理的默认行为 → 必填 → 接口风格。有合理默认 → 选填 → 混合。
读源码遇到一个 ops 表先看父类的统一接口怎么处理 NULL:每一个 NULL 都 assert,那是接口;有些 NULL 走默认行为,那是混合。
14.7.4 __cxa_pure_virtual:C++ 里那个占位地址
virtual int on() = 0; /* 纯虚 */
C++ 这个 = 0 不是赋值。它是一个语法占位,告诉编译器“这个函数没实现,子类必须实现“。底层做法是给虚函数表的对应槽位填一个特殊地址(一般是 __cxa_pure_virtual),子类没覆写就调到这个特殊函数,运行时报“pure virtual function called“。
C 里你手动让 ops 表的对应字段保持 NULL,再在统一接口里 assert,做的是同一件事。C++ 把这一招用语法藏起来,C 让你看见齿轮。
和你 ch11 学的 ops 分发是同一个机制。
14.7.5 默认行为长什么样
led_set_brightness 的默认是“安静跳过“。但默认也可以是别的。比如:
int led_set_brightness(struct led_base *me, uint8_t brightness)
{
if (!me || !me->ops)
return -1;
if (me->ops->set_brightness)
return me->ops->set_brightness(me, brightness);
/* 默认:用 on/off 模拟 brightness */
if (brightness > 50)
return me->ops->on(me);
else
return me->ops->off(me);
}
GPIO 灯虽然不支持真调光,亮度大于 50% 就开、小于就关,也是一种合理默认。
默认行为是父类设计的责任。子类要的“非默认“才填 ops 字段。
14.7.6 配套代码文件组织:封装延续 ch12 / ch13
本章 pc/ 目录沿用第 12、13 章的“每个子类一个文件 + leds.h + xxx_board_init.c“封装:
oop-in-c/code/14-pure-virtual/pc/
├── led_base.h / led_base.c led 父类层公开头:字段集 + ops 表 + 共有 init + 父类统一接口(必填 + 选填)
├── led_gpio.h / led_gpio.c GPIO 子类(只填 on / off,演示选填策略)
├── led_pwm.h / led_pwm.c PWM 子类(三件套全填)
├── leds.h LED 模块对外暴露的全局句柄
├── led_board_init.c LED 板级配置(GPIO + PWM 实例化 + 句柄绑定)
├── sensor_base.h / .c sensor 父类层公开头:字段集 + ops 表 + 共有 init + 父类统一接口(全必填 · 接口风格)
├── sensor_temp.h / .c temp_sensor 子类(三件套全填)
├── sensors.h sensor 模块对外暴露的全局句柄
├── sensor_board_init.c sensor 板级配置
├── container_of.h 第 13 章学的三步宏
└── main.c 跑 demo(应用层零硬件字样)
三种 ops 策略(必填 / 选填 / 接口)是子类内部 + 父类统一接口的事,跟应用层封装层无关。所以 main.c 在 ch12 / ch13 的封装基础上一字不退:只 #include "leds.h" + #include "sensors.h",看父类指针句柄走父类统一接口跑三段演示。
led 这条线和 sensor 这条线是两份完全独立的父类。led_base 演示必填 + 选填混合策略,sensor_base 演示全必填的接口策略。把它们放在同一个工程里,主要是让“同一份字段集类型 + 不同 NULL 处理纪律“在一次 ./demo 里看全。每个外设各一份 xxx_board_init.c(LED 模块的 led_board_init.c + sensor 模块的 sensor_board_init.c),谁的硬件参数谁负责,对应真实工程“每个外设各管自己“的工程纪律。
两条线的文件组织一致:父类层公开头(字段集 + ops 表 + 共有 init + 父类统一接口)集中在 xxx_base.h / .c 一对文件,子类各自一对独立的 .h / .c。sensor 这条线本章只有一种子类 temp_sensor,对应 sensor_temp.h / sensor_temp.c;后续章节如果引入第二种 sensor 子类(比如 pressure_sensor),再加一对 sensor_pressure.h / .c 即可,原有文件一字不动。
14.8 工程合同
接口是一份合同 · 签了就必须全部履行。
14.9 你现在的代码在 STM32 上长什么样
assert 在 STM32 上要小心:默认实现会调 __assert_func,里面常常是 printf("..."); abort()。printf 在 MCU 上要 retarget UART,abort 默认会跑 _exit 死循环。生产代码倾向于把 assert 替换成项目自己的错误日志 + 复位机制:
#define ASSERT(cond) \
do { \
if (!(cond)) { \
error_log(__FILE__, __LINE__, #cond); \
system_reset(); \
} \
} while (0)
错误日志写到非易失存储,下次开机能拉出来看哪一行炸了。完整 STM32 snippet 见 oop-in-c/code/14-pure-virtual/platform-mcu/stm32/(用 PIN_NUM('A', 13) 编码 + _gpio_table 查表)。完整跑通的 STM32 工程见附录 B。
14.10 工业代码里的策略选择
工业控制板的 led 模块用混合策略(on / off 必填,set_brightness 选填),和本章 14.3 节一致。motor 模块就不一样,motor_base 的 ops 表有 24 个 op,全部必填:
struct motor_ops {
int (*init)(struct motor_base *me, const struct motor_cfg *cfg);
int (*deinit)(struct motor_base *me);
int (*enable)(struct motor_base *me);
int (*disable)(struct motor_base *me);
int (*set_target_position)(struct motor_base *me, int32_t pos);
int (*get_current_position)(struct motor_base *me, int32_t *pos);
int (*set_target_speed)(struct motor_base *me, int32_t speed);
int (*emergency_stop)(struct motor_base *me);
/* ... 还有 16 个 ... */
};
电机这种安全相关、行为复杂的对象,每一个 op 子类都必须明确表态,能做就实现,不能做就报错返回。没有“合理默认“。motor_ops 是接口风格的典型。
判据回到 14.7.3:有没有合理的默认。没有,全必填,做成接口。有,混合。
14.11 跑一遍
cd oop-in-c/code/14-pure-virtual/pc
make
./demo
输出节选:
=========================================
ch14 - pure virtual / virtual / interface
=========================================
[base] "ERR" common init done, ops=...
[GPIO] PA.10 init as OUTPUT
[GPIO] PA.10 -> LOW (OFF)
[base] "STAT" common init done, ops=...
[base] "TEMP" sensor common init done, ops=...
--- 1. GPIO LED, no dimming support ---
[GPIO] PA.10 -> HIGH (ON)
[ERR] GPIO Pin10 ON
[GPIO] PA.10 -> LOW (OFF)
[ERR] GPIO Pin10 OFF
[ERR] no dimming support, skip (brightness=50)
--- 2. PWM LED, full ops ---
[STAT] PWM ch1 duty=50%
[STAT] PWM ch1 duty=70%
[STAT] PWM ch1 duty=0%
--- 3. sensor, all required ---
[TEMP] self_test
[TEMP] calibrate
[TEMP] read = 25 C
GPIO 灯调 set_brightness 走默认行为(“no dimming support, skip”),不崩。PWM 灯三件套全填,每次都走子类。sensor 三件套全必填,调过去都能进子类实现。
如果你想体验“忘了填 on“的崩溃:把 led_gpio_init 里的 led_base_init(&me->base, name, &led_ops_gpio); 这一行注释掉,me->base.ops 就是没装上的状态,再编译跑,led_on 里的 assert 立刻报错。
14.12 视频回放
下一章
封装、继承、多态、向上转型、向下转型、纯虚 / 虚 / 接口,C 里做 OOP 的全套武器你都见过了。
但你还没见过它们组装在一起在一个真实项目里跑的样子。下一章把武器全部组装起来,演示一套完整的 LED 框架,换硬件应用层 0 改动。